@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
@@ -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,90 @@
1
+ export type DemoArticleRecord = {
2
+ id: string;
3
+ type: string;
4
+ slug: string;
5
+ section: string | null;
6
+ title: string;
7
+ status: "draft" | "published";
8
+ frontMatterYaml: string;
9
+ bodyMarkus: string;
10
+ contentHash: string;
11
+ publishedContentHash: string | null;
12
+ versionNumber: number | null;
13
+ everPublished: boolean;
14
+ updatedAt: string;
15
+ };
16
+
17
+ export function demoContentHash(frontMatterYaml: string, bodyMarkus: string): string {
18
+ let hash = 5381;
19
+ const text = `${frontMatterYaml}\u0000${bodyMarkus}`;
20
+ for (let index = 0; index < text.length; index += 1) {
21
+ hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0;
22
+ }
23
+ return `demo:${hash.toString(16)}`;
24
+ }
25
+
26
+ function demoRecord(
27
+ fields: Pick<DemoArticleRecord, "id" | "slug" | "section" | "title" | "bodyMarkus" | "updatedAt"> & {
28
+ type?: string;
29
+ status: "draft" | "published";
30
+ publishedBody?: string;
31
+ versionNumber: number | null;
32
+ },
33
+ ): DemoArticleRecord {
34
+ const frontMatterYaml = `title: ${fields.title}\n`;
35
+ const contentHash = demoContentHash(frontMatterYaml, fields.bodyMarkus);
36
+ const publishedContentHash = fields.status === "published"
37
+ ? demoContentHash(frontMatterYaml, fields.publishedBody ?? fields.bodyMarkus)
38
+ : null;
39
+ return {
40
+ id: fields.id,
41
+ type: fields.type ?? "article",
42
+ slug: fields.slug,
43
+ section: fields.section,
44
+ title: fields.title,
45
+ status: fields.status,
46
+ frontMatterYaml,
47
+ bodyMarkus: fields.bodyMarkus,
48
+ contentHash,
49
+ publishedContentHash,
50
+ versionNumber: fields.versionNumber,
51
+ everPublished: fields.status === "published",
52
+ updatedAt: fields.updatedAt,
53
+ };
54
+ }
55
+
56
+ export function createDemoArticleRecords(): DemoArticleRecord[] {
57
+ return [
58
+ demoRecord({
59
+ id: "demo-article-changed",
60
+ slug: "forty-billion-dollars",
61
+ section: "business",
62
+ title: "Forty Billion Dollars",
63
+ bodyMarkus: "The raise doubled again this quarter.\n\nAnalysts expect the pace to slow.\n",
64
+ publishedBody: "The raise doubled this quarter.\n",
65
+ status: "published",
66
+ versionNumber: 2,
67
+ updatedAt: "2026-10-05T16:00:00.000Z",
68
+ }),
69
+ demoRecord({
70
+ id: "demo-article-published",
71
+ slug: "harbor-cranes-return",
72
+ section: "local",
73
+ title: "Harbor Cranes Return",
74
+ bodyMarkus: "# Harbor Cranes Return\n\nThe cranes were lifted back into place on Monday.\n",
75
+ status: "published",
76
+ versionNumber: 1,
77
+ updatedAt: "2026-10-04T12:00:00.000Z",
78
+ }),
79
+ demoRecord({
80
+ id: "demo-article-draft",
81
+ slug: "council-budget-notes",
82
+ section: "politics",
83
+ title: "Council Budget Notes",
84
+ bodyMarkus: "Notes from the budget session.\n",
85
+ status: "draft",
86
+ versionNumber: null,
87
+ updatedAt: "2026-10-03T09:00:00.000Z",
88
+ }),
89
+ ];
90
+ }
@@ -0,0 +1,142 @@
1
+ import { generateClient } from "aws-amplify/data";
2
+ import { getUrl, uploadData } from "aws-amplify/storage";
3
+ import type { Schema } from "../amplify/data/resource";
4
+ import type { MediaAssetFields, MediaAssetRecord, NewsroomMediaBackend } from "./newsroom-media";
5
+
6
+ type MediaListPage = {
7
+ data?: (Record<string, unknown> | null)[] | null;
8
+ nextToken?: string | null;
9
+ errors?: { message?: string }[] | null;
10
+ };
11
+
12
+ export type MediaDataClient = {
13
+ models: {
14
+ MediaAsset: {
15
+ create: (
16
+ input: MediaAssetFields,
17
+ options: { authMode: "userPool" },
18
+ ) => Promise<{ errors?: { message?: string }[] | null }>;
19
+ listMediaAssetsByItemAndSortKey: (
20
+ input: { itemId: string },
21
+ options: { authMode: "userPool"; nextToken?: string | null; limit?: number },
22
+ ) => Promise<MediaListPage>;
23
+ };
24
+ };
25
+ };
26
+
27
+ export type MediaStorageApi = {
28
+ upload: (input: { path: string; data: Blob; contentType: string }) => Promise<void>;
29
+ signedUrl: (path: string) => Promise<string>;
30
+ };
31
+
32
+ function failOnErrors(errors: { message?: string }[] | null | undefined): void {
33
+ if (errors && errors.length > 0) {
34
+ throw new Error(errors.map((error) => error.message ?? "GraphQL error").join("; "));
35
+ }
36
+ }
37
+
38
+ function srcPathFromMetadata(metadata: unknown): string | null {
39
+ let parsed = metadata;
40
+ if (typeof metadata === "string") {
41
+ try {
42
+ parsed = JSON.parse(metadata);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ if (typeof parsed !== "object" || parsed === null) return null;
48
+ const srcPath = (parsed as Record<string, unknown>).srcPath;
49
+ return typeof srcPath === "string" ? srcPath : null;
50
+ }
51
+
52
+ export function createMediaBackend(client: () => MediaDataClient, storage: MediaStorageApi): NewsroomMediaBackend {
53
+ return {
54
+ uploadImage: ({ storagePath, data, contentType }) =>
55
+ storage.upload({ path: storagePath, data, contentType }),
56
+ async createMediaAsset(fields) {
57
+ const response = await client().models.MediaAsset.create(fields, { authMode: "userPool" });
58
+ failOnErrors(response.errors);
59
+ },
60
+ async listMediaAssets(itemId) {
61
+ const records: MediaAssetRecord[] = [];
62
+ let nextToken: string | null | undefined = null;
63
+ do {
64
+ const page: MediaListPage = await client().models.MediaAsset.listMediaAssetsByItemAndSortKey(
65
+ { itemId },
66
+ { authMode: "userPool", nextToken, limit: 500 },
67
+ );
68
+ failOnErrors(page.errors);
69
+ for (const row of page.data ?? []) {
70
+ if (!row) continue;
71
+ records.push({
72
+ id: String(row.id),
73
+ sortKey: String(row.sortKey),
74
+ storagePath: typeof row.storagePath === "string" ? row.storagePath : null,
75
+ alt: typeof row.alt === "string" ? row.alt : "",
76
+ srcPath: srcPathFromMetadata(row.metadata),
77
+ });
78
+ }
79
+ nextToken = page.nextToken;
80
+ } while (nextToken);
81
+ return records;
82
+ },
83
+ imageUrl: (storagePath) => storage.signedUrl(storagePath),
84
+ };
85
+ }
86
+
87
+ const amplifyStorage: MediaStorageApi = {
88
+ async upload({ path, data, contentType }) {
89
+ await uploadData({ path, data, options: { contentType } }).result;
90
+ },
91
+ async signedUrl(path) {
92
+ const { url } = await getUrl({ path });
93
+ return url.toString();
94
+ },
95
+ };
96
+
97
+ export function createLiveMediaBackend(): NewsroomMediaBackend {
98
+ let client: MediaDataClient | null = null;
99
+ return createMediaBackend(() => {
100
+ if (!client) client = generateClient<Schema>() as unknown as MediaDataClient;
101
+ return client;
102
+ }, amplifyStorage);
103
+ }
104
+
105
+ export function createDemoMediaBackend(): NewsroomMediaBackend {
106
+ const objects = new Map<string, Blob>();
107
+ const rows = new Map<string, MediaAssetFields[]>();
108
+ return {
109
+ async uploadImage({ storagePath, data }) {
110
+ objects.set(storagePath, data);
111
+ },
112
+ async createMediaAsset(fields) {
113
+ rows.set(fields.itemId, [...(rows.get(fields.itemId) ?? []), fields]);
114
+ },
115
+ async listMediaAssets(itemId) {
116
+ return (rows.get(itemId) ?? []).map((fields) => ({
117
+ id: fields.id,
118
+ sortKey: fields.sortKey,
119
+ storagePath: fields.storagePath,
120
+ alt: fields.alt,
121
+ srcPath: srcPathFromMetadata(fields.metadata),
122
+ }));
123
+ },
124
+ async imageUrl(storagePath) {
125
+ const object = objects.get(storagePath);
126
+ if (!object) throw new Error(`No demo object at ${storagePath}.`);
127
+ return URL.createObjectURL(object);
128
+ },
129
+ };
130
+ }
131
+
132
+ let liveBackend: NewsroomMediaBackend | null = null;
133
+ let demoBackend: NewsroomMediaBackend | null = null;
134
+
135
+ export function resolveMediaBackend(demo: boolean): NewsroomMediaBackend {
136
+ if (demo) {
137
+ demoBackend ??= createDemoMediaBackend();
138
+ return demoBackend;
139
+ }
140
+ liveBackend ??= createLiveMediaBackend();
141
+ return liveBackend;
142
+ }