@anthusai/papyrus 0.0.0 → 1.0.0-next.10

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 (282) 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 +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -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/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,325 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import type { Schema } from "../amplify/data/resource";
3
+ import {
4
+ deriveMarkus,
5
+ publishItem,
6
+ saveItemDraft,
7
+ unpublishItem,
8
+ type ContentActionError,
9
+ } from "./content-actions-client";
10
+ import { createDemoArticlesBackend } from "./content-actions-demo";
11
+
12
+ export type ArticleStatus = "draft" | "published";
13
+ export type ArticleType = "article" | "page";
14
+
15
+ export const ARTICLE_TYPES: ArticleType[] = ["article", "page"];
16
+
17
+ export type NewsroomArticleRow = {
18
+ id: string;
19
+ type: string;
20
+ slug: string;
21
+ section: string | null;
22
+ title: string;
23
+ status: ArticleStatus;
24
+ pending: boolean;
25
+ updatedAt: string;
26
+ };
27
+
28
+ export type ArticleItemSummary = {
29
+ id: string;
30
+ lineageId?: string | null;
31
+ type: string;
32
+ slug: string;
33
+ section?: string | null;
34
+ title?: string | null;
35
+ status: string;
36
+ contentHash?: string | null;
37
+ updatedAt?: string | null;
38
+ };
39
+
40
+ export type PublishedItemSummary = {
41
+ id: string;
42
+ itemLineageId?: string | null;
43
+ sourceItemId?: string | null;
44
+ metadata?: unknown;
45
+ };
46
+
47
+ export type NewsroomArticleEditorState = {
48
+ id: string | null;
49
+ type: string;
50
+ section: string;
51
+ slug: string;
52
+ frontMatterYaml: string;
53
+ bodyMarkus: string;
54
+ contentHash: string | null;
55
+ status: ArticleStatus;
56
+ versionNumber: number | null;
57
+ dirty: boolean;
58
+ };
59
+
60
+ export type EditableArticle = {
61
+ id: string;
62
+ type: string;
63
+ section: string | null;
64
+ slug: string;
65
+ frontMatterYaml: string;
66
+ bodyMarkus: string;
67
+ contentHash: string | null;
68
+ status: ArticleStatus;
69
+ versionNumber: number | null;
70
+ aliases: string[];
71
+ everPublished: boolean;
72
+ };
73
+
74
+ export type ArticlesBackend = {
75
+ actions: {
76
+ deriveMarkus: typeof deriveMarkus;
77
+ saveItemDraft: typeof saveItemDraft;
78
+ publishItem: typeof publishItem;
79
+ unpublishItem: typeof unpublishItem;
80
+ };
81
+ listRows: () => Promise<NewsroomArticleRow[]>;
82
+ loadArticle: (id: string) => Promise<EditableArticle>;
83
+ };
84
+
85
+ export function slugify(value: string): string {
86
+ return value
87
+ .normalize("NFKD")
88
+ .replace(/[^\x00-\x7F]/g, "")
89
+ .toLowerCase()
90
+ .replace(/[^a-z0-9]+/g, "-")
91
+ .replace(/^-+|-+$/g, "");
92
+ }
93
+
94
+ export function suggestSlug(frontMatterYaml: string): string {
95
+ for (const line of frontMatterYaml.split("\n")) {
96
+ const match = /^title\s*:\s*(.*)$/.exec(line);
97
+ if (!match) continue;
98
+ let value = match[1].trim();
99
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
100
+ value = value.slice(1, -1);
101
+ }
102
+ return slugify(value);
103
+ }
104
+ return "";
105
+ }
106
+
107
+ export function errorLine(errors: ContentActionError[]): number | null {
108
+ for (const error of errors) {
109
+ if (typeof error.line === "number") return error.line;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export function sourceLineOffset(frontMatterYaml: string | null): number {
115
+ const yamlText = (frontMatterYaml ?? "").replace(/^\n+|\n+$/g, "");
116
+ if (!yamlText.trim()) return 0;
117
+ return yamlText.split("\n").length + 2;
118
+ }
119
+
120
+ export type ErrorLocation = { field: "frontMatterYaml" | "bodyMarkus"; line: number | null };
121
+
122
+ export function locateError(error: ContentActionError, frontMatterYaml: string | null): ErrorLocation {
123
+ if (error.code === "front-matter") return { field: "frontMatterYaml", line: null };
124
+ if (error.line === null) return { field: "bodyMarkus", line: null };
125
+ const bodyLine = error.line - sourceLineOffset(frontMatterYaml);
126
+ if (bodyLine >= 1) return { field: "bodyMarkus", line: bodyLine };
127
+ return { field: "frontMatterYaml", line: Math.max(error.line - 1, 1) };
128
+ }
129
+
130
+ export function emptyArticle(): NewsroomArticleEditorState {
131
+ return {
132
+ id: null,
133
+ type: "article",
134
+ section: "",
135
+ slug: "",
136
+ frontMatterYaml: "title: \n",
137
+ bodyMarkus: "",
138
+ contentHash: null,
139
+ status: "draft",
140
+ versionNumber: null,
141
+ dirty: false,
142
+ };
143
+ }
144
+
145
+ function parseJsonObject(value: unknown): Record<string, unknown> {
146
+ if (typeof value === "string") {
147
+ try {
148
+ const parsed = JSON.parse(value);
149
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
155
+ }
156
+
157
+ export function hasPendingChanges(item: ArticleItemSummary, published: PublishedItemSummary | null): boolean {
158
+ if (!published) return false;
159
+ return item.contentHash !== parseJsonObject(published.metadata).sourceContentHash;
160
+ }
161
+
162
+ export function articleStatusLabel(item: ArticleItemSummary, published: PublishedItemSummary | null): string {
163
+ if (item.status !== "published") return "Draft";
164
+ return hasPendingChanges(item, published) ? "Published, unpublished changes" : "Published";
165
+ }
166
+
167
+ function rowFromItem(item: ArticleItemSummary, published: PublishedItemSummary | null): NewsroomArticleRow {
168
+ const status: ArticleStatus = item.status === "published" ? "published" : "draft";
169
+ return {
170
+ id: item.id,
171
+ type: item.type,
172
+ slug: item.slug,
173
+ section: item.section ?? null,
174
+ title: item.title || item.slug,
175
+ status,
176
+ pending: status === "published" && hasPendingChanges(item, published),
177
+ updatedAt: item.updatedAt ?? "",
178
+ };
179
+ }
180
+
181
+ export function sortRowsByUpdatedAtDescending(rows: NewsroomArticleRow[]): NewsroomArticleRow[] {
182
+ return [...rows].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
183
+ }
184
+
185
+ export function rowStatusLabel(row: NewsroomArticleRow): string {
186
+ if (row.status === "draft") return "Draft";
187
+ return row.pending ? "Published, unpublished changes" : "Published";
188
+ }
189
+
190
+ type ListPage<T> = { data?: (T | null)[] | null; nextToken?: string | null; errors?: { message?: string }[] | null };
191
+ type ListOptions = { authMode: "userPool"; nextToken?: string | null; limit?: number };
192
+
193
+ export type ArticlesDataClient = {
194
+ models: {
195
+ Item: {
196
+ listItemsByTypeStatusAndPublishedAt: (
197
+ input: { typeStatus: string },
198
+ options: ListOptions,
199
+ ) => Promise<ListPage<ArticleItemSummary>>;
200
+ get: (
201
+ input: { id: string },
202
+ options: { authMode: "userPool" },
203
+ ) => Promise<{ data?: Record<string, unknown> | null; errors?: { message?: string }[] | null }>;
204
+ };
205
+ PublishedItem: {
206
+ listPublishedItemsByTypeStatusAndPublishedAt: (
207
+ input: { typeStatus: string },
208
+ options: ListOptions,
209
+ ) => Promise<ListPage<PublishedItemSummary>>;
210
+ };
211
+ };
212
+ };
213
+
214
+ const NEWSROOM_ARTICLE_TYPE_STATUSES = ["article#draft", "article#published", "page#draft", "page#published"];
215
+ const NEWSROOM_PUBLISHED_TYPE_STATUSES = ["article#published", "page#published"];
216
+
217
+ async function collectPages<T>(
218
+ fetchPage: (options: ListOptions) => Promise<ListPage<T>>,
219
+ ): Promise<T[]> {
220
+ const collected: T[] = [];
221
+ let nextToken: string | null | undefined = null;
222
+ do {
223
+ const page: ListPage<T> = await fetchPage({ authMode: "userPool", nextToken, limit: 500 });
224
+ if (page.errors && page.errors.length > 0) {
225
+ throw new Error(page.errors.map((error) => error.message ?? "GraphQL error").join("; "));
226
+ }
227
+ for (const row of page.data ?? []) {
228
+ if (row) collected.push(row);
229
+ }
230
+ nextToken = page.nextToken;
231
+ } while (nextToken);
232
+ return collected;
233
+ }
234
+
235
+ export async function loadArticleRows(client: ArticlesDataClient): Promise<NewsroomArticleRow[]> {
236
+ const itemGroups = await Promise.all(
237
+ NEWSROOM_ARTICLE_TYPE_STATUSES.map((typeStatus) =>
238
+ collectPages((options) => client.models.Item.listItemsByTypeStatusAndPublishedAt({ typeStatus }, options)),
239
+ ),
240
+ );
241
+ const publishedGroups = await Promise.all(
242
+ NEWSROOM_PUBLISHED_TYPE_STATUSES.map((typeStatus) =>
243
+ collectPages((options) =>
244
+ client.models.PublishedItem.listPublishedItemsByTypeStatusAndPublishedAt({ typeStatus }, options),
245
+ ),
246
+ ),
247
+ );
248
+ const publishedByLineage = new Map<string, PublishedItemSummary>();
249
+ for (const published of publishedGroups.flat()) {
250
+ const lineage = published.itemLineageId ?? published.sourceItemId;
251
+ if (lineage) publishedByLineage.set(lineage, published);
252
+ }
253
+ const rows = itemGroups
254
+ .flat()
255
+ .map((item) => rowFromItem(item, publishedByLineage.get(item.lineageId ?? item.id) ?? null));
256
+ return sortRowsByUpdatedAtDescending(rows);
257
+ }
258
+
259
+ export async function loadEditableArticle(client: ArticlesDataClient, id: string): Promise<EditableArticle> {
260
+ const response = await client.models.Item.get({ id }, { authMode: "userPool" });
261
+ if (response.errors && response.errors.length > 0) {
262
+ throw new Error(response.errors.map((error) => error.message ?? "GraphQL error").join("; "));
263
+ }
264
+ const record = response.data;
265
+ if (!record) throw new Error("That article was not found.");
266
+ const metadata = parseJsonObject(record.metadata);
267
+ const status: ArticleStatus = record.status === "published" ? "published" : "draft";
268
+ return {
269
+ id: String(record.id),
270
+ type: String(record.type),
271
+ section: typeof record.section === "string" ? record.section : null,
272
+ slug: String(record.slug),
273
+ frontMatterYaml: typeof metadata.frontMatterYaml === "string" ? metadata.frontMatterYaml : "",
274
+ bodyMarkus: typeof record.bodyMarkus === "string" ? record.bodyMarkus : "",
275
+ contentHash: typeof record.contentHash === "string" ? record.contentHash : null,
276
+ status,
277
+ versionNumber: typeof record.versionNumber === "number" ? record.versionNumber : null,
278
+ aliases: Array.isArray(record.aliases) ? record.aliases.map(String) : [],
279
+ everPublished: status === "published",
280
+ };
281
+ }
282
+
283
+ export function createLiveArticlesBackend(): ArticlesBackend {
284
+ let client: ArticlesDataClient | null = null;
285
+ const dataClient = () => {
286
+ if (!client) client = generateClient<Schema>() as unknown as ArticlesDataClient;
287
+ return client;
288
+ };
289
+ return {
290
+ actions: { deriveMarkus, saveItemDraft, publishItem, unpublishItem },
291
+ listRows: () => loadArticleRows(dataClient()),
292
+ loadArticle: (id) => loadEditableArticle(dataClient(), id),
293
+ };
294
+ }
295
+
296
+ let liveBackend: ArticlesBackend | null = null;
297
+ let demoBackend: ArticlesBackend | null = null;
298
+
299
+ export function resolveArticlesBackend(demo: boolean): ArticlesBackend {
300
+ if (demo) {
301
+ demoBackend ??= createDemoArticlesBackend();
302
+ return demoBackend;
303
+ }
304
+ liveBackend ??= createLiveArticlesBackend();
305
+ return liveBackend;
306
+ }
307
+
308
+ export function stagingPreviewUrl(
309
+ stagingBaseUrl: string | undefined,
310
+ type: string,
311
+ slug: string,
312
+ staticSite: boolean,
313
+ ): string | null {
314
+ if (!stagingBaseUrl || !slug || type !== "article") return null;
315
+ const base = stagingBaseUrl.replace(/\/+$/, "");
316
+ return staticSite ? `${base}/articles/${slug}.html` : `${base}/articles/${slug}`;
317
+ }
318
+
319
+ export function uniqueSections(rows: NewsroomArticleRow[]): string[] {
320
+ const sections = new Set<string>();
321
+ for (const row of rows) {
322
+ if (row.section) sections.add(row.section);
323
+ }
324
+ return [...sections].sort();
325
+ }
@@ -0,0 +1,173 @@
1
+ import type { AssignmentRecord, NewsroomSummaryRecord } from "./category-repository";
2
+
3
+ export type AssignmentStatusFilter = "all" | "open" | "claimed" | "completed" | "canceled";
4
+
5
+ export type AssignmentAction = "claim" | "release" | "complete" | "cancel" | "reopen" | "retry";
6
+
7
+ export type AssignmentTypeOption = {
8
+ key: string;
9
+ label: string;
10
+ count: number;
11
+ };
12
+
13
+ export type AssignmentMetrics = {
14
+ total: number;
15
+ open: number;
16
+ claimed: number;
17
+ completed: number;
18
+ canceled: number;
19
+ };
20
+
21
+ export function assignmentTypeKeyForFilter(assignment: AssignmentRecord): string {
22
+ return assignment.assignmentTypeKey?.trim() || "unknown";
23
+ }
24
+
25
+ export function formatAssignmentTypeLabel(typeKey: string | null | undefined): string {
26
+ const key = typeKey?.trim();
27
+ if (!key) return "Uncategorized";
28
+ if (key === "analysis.reindex") return "Analysis re-index";
29
+ if (key === "newsroom.research" || key === "research") return "Research";
30
+ if (key === "curation.reference-intake") return "Reference intake";
31
+ if (key === "reporting.edition-candidate") return "Reporting candidate";
32
+ return key
33
+ .split(/[.:_-]+/)
34
+ .filter(Boolean)
35
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
36
+ .join(" ");
37
+ }
38
+
39
+ export function getAssignmentTypeOptions(
40
+ assignments: AssignmentRecord[],
41
+ summary?: NewsroomSummaryRecord | null,
42
+ ): AssignmentTypeOption[] {
43
+ const summaryCounts = summary?.facets?.assignments?.byType ?? summary?.assignmentTypeCounts;
44
+ const countByType = summaryCounts && Object.keys(summaryCounts).length
45
+ ? new Map(Object.entries(summaryCounts))
46
+ : new Map<string, number>();
47
+ if (!countByType.size) {
48
+ for (const assignment of assignments) {
49
+ const typeKey = assignmentTypeKeyForFilter(assignment);
50
+ countByType.set(typeKey, (countByType.get(typeKey) ?? 0) + 1);
51
+ }
52
+ }
53
+ return Array.from(countByType.entries())
54
+ .map(([key, count]) => ({
55
+ key,
56
+ label: formatAssignmentTypeLabel(key),
57
+ count,
58
+ }))
59
+ .sort((left, right) => {
60
+ const countDiff = right.count - left.count;
61
+ if (countDiff !== 0) return countDiff;
62
+ return left.label.localeCompare(right.label);
63
+ });
64
+ }
65
+
66
+ export function getAssignmentMetrics(
67
+ assignments: AssignmentRecord[],
68
+ summary?: NewsroomSummaryRecord | null,
69
+ typeFilter?: string,
70
+ ): AssignmentMetrics {
71
+ const statusCounts = typeFilter
72
+ ? summary?.facets?.assignments?.statusByType?.[typeFilter]
73
+ : summary?.facets?.assignments?.byStatus ?? summary?.assignmentStatusCounts;
74
+ if (statusCounts && Object.keys(statusCounts).length) {
75
+ return {
76
+ total: Object.values(statusCounts).reduce((sum, count) => sum + count, 0),
77
+ open: statusCounts.open ?? 0,
78
+ claimed: statusCounts.claimed ?? 0,
79
+ completed: statusCounts.completed ?? 0,
80
+ canceled: statusCounts.canceled ?? 0,
81
+ };
82
+ }
83
+ return {
84
+ total: assignments.length,
85
+ open: assignments.filter((assignment) => assignment.status === "open").length,
86
+ claimed: assignments.filter((assignment) => assignment.status === "claimed").length,
87
+ completed: assignments.filter((assignment) => assignment.status === "completed").length,
88
+ canceled: assignments.filter((assignment) => assignment.status === "canceled").length,
89
+ };
90
+ }
91
+
92
+ function assignmentStatusRank(status: string): number {
93
+ if (status === "open") return 0;
94
+ if (status === "claimed") return 1;
95
+ if (status === "completed") return 6;
96
+ if (status === "canceled") return 8;
97
+ return 5;
98
+ }
99
+
100
+ export function compareAssignments(left: AssignmentRecord, right: AssignmentRecord): number {
101
+ const leftStatus = assignmentStatusRank(left.status);
102
+ const rightStatus = assignmentStatusRank(right.status);
103
+ if (leftStatus !== rightStatus) return leftStatus - rightStatus;
104
+ const priorityDiff = (left.priority ?? 999999) - (right.priority ?? 999999);
105
+ if (priorityDiff !== 0) return priorityDiff;
106
+ return left.createdAt.localeCompare(right.createdAt);
107
+ }
108
+
109
+ export function countAssignmentsByStatus(assignments: AssignmentRecord[]): Record<string, number> {
110
+ const counts: Record<string, number> = {};
111
+ for (const assignment of assignments) {
112
+ const status = assignment.status?.trim().toLowerCase() || "unknown";
113
+ counts[status] = (counts[status] ?? 0) + 1;
114
+ }
115
+ return counts;
116
+ }
117
+
118
+ export function filterAssignmentsByStatus(
119
+ assignments: AssignmentRecord[],
120
+ statusFilter: AssignmentStatusFilter,
121
+ ): AssignmentRecord[] {
122
+ const sorted = [...assignments].sort(compareAssignments);
123
+ if (statusFilter === "all") return sorted;
124
+ return sorted.filter((assignment) => assignment.status === statusFilter);
125
+ }
126
+
127
+ export function formatAssignmentListDate(assignment: AssignmentRecord): string {
128
+ const value = assignment.updatedAt ?? assignment.createdAt ?? "";
129
+ if (!value) return "Undated";
130
+ const timestamp = Date.parse(value);
131
+ if (Number.isNaN(timestamp)) return value;
132
+ return new Intl.DateTimeFormat("en-US", {
133
+ month: "short",
134
+ day: "numeric",
135
+ year: "numeric",
136
+ timeZone: "UTC",
137
+ }).format(new Date(timestamp));
138
+ }
139
+
140
+ export function assignmentStatusLabel(status: string): string {
141
+ const normalized = status?.trim().toLowerCase() ?? "";
142
+ if (normalized === "open") return "Open";
143
+ if (normalized === "claimed") return "Claimed";
144
+ if (normalized === "completed") return "Completed";
145
+ if (normalized === "canceled") return "Canceled";
146
+ return status || "Unknown";
147
+ }
148
+
149
+ export function assignmentStatusBadgeVariant(
150
+ status: string,
151
+ ): "default" | "secondary" | "outline" | "muted" {
152
+ const normalized = status?.trim().toLowerCase() ?? "";
153
+ if (normalized === "open") return "default";
154
+ if (normalized === "claimed") return "secondary";
155
+ if (normalized === "completed") return "outline";
156
+ if (normalized === "canceled") return "outline";
157
+ return "muted";
158
+ }
159
+
160
+ export function assignmentListSubtitle(assignment: AssignmentRecord): string {
161
+ const typeLabel = formatAssignmentTypeLabel(assignment.assignmentTypeKey);
162
+ const section = assignment.sectionKey?.trim() || assignment.sectionId?.trim();
163
+ if (section) return `${typeLabel} · ${section}`;
164
+ return typeLabel;
165
+ }
166
+
167
+ export function selectedAssignmentById(
168
+ assignments: AssignmentRecord[],
169
+ assignmentId: string | null | undefined,
170
+ ): AssignmentRecord | null {
171
+ if (!assignmentId) return null;
172
+ return assignments.find((assignment) => assignment.id === assignmentId) ?? null;
173
+ }
@@ -0,0 +1,48 @@
1
+ import { SITE_BRAND } from "./site-brand";
2
+
3
+ const DEFAULT_NEWSROOM_BASE_PATH = "/newsroom";
4
+
5
+ /** Internal Next.js route prefix (always `/newsroom/...`). */
6
+ export function getInternalNewsroomBasePath(): string {
7
+ return DEFAULT_NEWSROOM_BASE_PATH;
8
+ }
9
+
10
+ /** Public browser path prefix (`""` on a CMS-only newsroom subdomain). */
11
+ export function getPublicNewsroomBasePath(): string {
12
+ return SITE_BRAND.newsroomBasePath ?? DEFAULT_NEWSROOM_BASE_PATH;
13
+ }
14
+
15
+ export function usesNewsroomRootPaths(): boolean {
16
+ return getPublicNewsroomBasePath() === "";
17
+ }
18
+
19
+ export function toPublicNewsroomPath(internalPath: string): string {
20
+ const publicBase = getPublicNewsroomBasePath();
21
+ if (publicBase !== "") return internalPath;
22
+ if (internalPath === "/newsroom" || internalPath === "/newsroom/") return "/";
23
+ if (internalPath.startsWith("/newsroom/")) {
24
+ return internalPath.slice("/newsroom".length) || "/";
25
+ }
26
+ return internalPath;
27
+ }
28
+
29
+ export function toInternalNewsroomPath(publicPath: string): string {
30
+ const publicBase = getPublicNewsroomBasePath();
31
+ const normalized = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
32
+ if (publicBase !== "") {
33
+ if (normalized === publicBase || normalized === `${publicBase}/`) return "/newsroom";
34
+ if (normalized.startsWith(`${publicBase}/`)) return normalized;
35
+ if (normalized.startsWith("/newsroom")) return normalized;
36
+ return `/newsroom${normalized === "/" ? "" : normalized}`;
37
+ }
38
+ if (normalized === "/" || normalized === "") return "/newsroom";
39
+ if (normalized.startsWith("/newsroom")) return normalized;
40
+ return `/newsroom${normalized}`;
41
+ }
42
+
43
+ export function newsroomHref(...segments: string[]): string {
44
+ const suffix = segments.filter(Boolean).join("/");
45
+ const publicBase = getPublicNewsroomBasePath();
46
+ if (!suffix) return publicBase || "/";
47
+ return publicBase ? `${publicBase}/${suffix}` : `/${suffix}`;
48
+ }
@@ -0,0 +1,102 @@
1
+ export type NewsroomCardSpan = "1x1" | "2x1" | "1x2" | "2x2" | "3x1" | "3x2";
2
+
3
+ export type NewsroomCardTemplateRole = "lead" | "priority" | "secondary" | "tall" | "wide" | "wideFeature" | "standard";
4
+
5
+ type NewsroomCardTemplateInput = {
6
+ mode: "desk" | "overview";
7
+ index: number;
8
+ bodyLength?: number;
9
+ isUrgent?: boolean;
10
+ qualityRating?: number | null;
11
+ sectionKey?: "assignments" | "messages" | "references" | string;
12
+ title?: string | null;
13
+ updatedAt?: string | null;
14
+ };
15
+
16
+ type NewsroomCardTemplate = {
17
+ role: NewsroomCardTemplateRole;
18
+ span: NewsroomCardSpan;
19
+ };
20
+
21
+ export function resolveNewsroomCardTemplate({
22
+ bodyLength = 0,
23
+ index,
24
+ isUrgent = false,
25
+ mode,
26
+ qualityRating,
27
+ sectionKey,
28
+ title,
29
+ }: NewsroomCardTemplateInput): NewsroomCardTemplate {
30
+ if (mode === "overview") {
31
+ if (index === 0) return { role: "lead", span: "2x2" };
32
+ if (index === 3) return { role: "secondary", span: "2x1" };
33
+ return { role: "standard", span: "1x1" };
34
+ }
35
+ if (sectionKey === "references") {
36
+ return resolveReferenceDeskTemplate({
37
+ bodyLength,
38
+ index,
39
+ isUrgent,
40
+ qualityRating,
41
+ title: title ?? "",
42
+ });
43
+ }
44
+ if (index === 0 || isUrgent && index < 3) return { role: "priority", span: "2x1" };
45
+ if (bodyLength > 180 || index > 0 && index % 7 === 0) return { role: "tall", span: "1x2" };
46
+ if (index > 0 && index % 11 === 0) return { role: "secondary", span: "2x1" };
47
+ return { role: "standard", span: "1x1" };
48
+ }
49
+
50
+ function resolveReferenceDeskTemplate({
51
+ bodyLength,
52
+ index,
53
+ isUrgent,
54
+ qualityRating,
55
+ title,
56
+ }: {
57
+ bodyLength: number;
58
+ index: number;
59
+ isUrgent: boolean;
60
+ qualityRating?: number | null;
61
+ title: string;
62
+ }): NewsroomCardTemplate {
63
+ if (isLowQualityRating(qualityRating)) return { role: "standard", span: "1x1" };
64
+
65
+ const pressure = headlinePressure(title);
66
+ const bandSlot = index % 12;
67
+ const isBandLead = bandSlot === 0;
68
+ const isBandSecondary = bandSlot === 4 || bandSlot === 8;
69
+
70
+ if (pressure === "severe") {
71
+ return isBandLead
72
+ ? { role: "wideFeature", span: "3x2" }
73
+ : { role: "wide", span: "3x1" };
74
+ }
75
+ if (pressure === "high") {
76
+ return isBandLead || isBandSecondary
77
+ ? { role: "wide", span: "3x1" }
78
+ : { role: "secondary", span: "2x1" };
79
+ }
80
+ if (pressure === "medium" && (bandSlot === 1 || bandSlot === 5 || bandSlot === 9)) {
81
+ return { role: "secondary", span: "2x1" };
82
+ }
83
+ if (bodyLength > 340 && bandSlot === 7) return { role: "tall", span: "2x2" };
84
+ if (bodyLength > 220 && (bandSlot === 2 || bandSlot === 7 || bandSlot === 10)) return { role: "tall", span: "1x2" };
85
+ if (index === 0 || isUrgent && index < 3) return { role: "priority", span: "2x1" };
86
+ if (index > 0 && index % 11 === 0) return { role: "secondary", span: "2x1" };
87
+ return { role: "standard", span: "1x1" };
88
+ }
89
+
90
+ function headlinePressure(title: string): "low" | "medium" | "high" | "severe" {
91
+ const words = title.trim().split(/\s+/).filter(Boolean);
92
+ const characterCount = words.join(" ").length;
93
+ const longestWord = words.reduce((max, word) => Math.max(max, word.length), 0);
94
+ if (characterCount >= 96 || longestWord >= 24) return "severe";
95
+ if (characterCount >= 72 || longestWord >= 18) return "high";
96
+ if (characterCount >= 52 || longestWord >= 14) return "medium";
97
+ return "low";
98
+ }
99
+
100
+ function isLowQualityRating(value: number | null | undefined): boolean {
101
+ return typeof value === "number" && Number.isFinite(value) && value < 3;
102
+ }