@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.
- package/LICENSE +21 -0
- package/amplify/auth/resource.js +61 -0
- package/amplify/auth/resource.ts +65 -0
- package/amplify/data/resource.js +2069 -0
- package/amplify/data/resource.ts +2185 -0
- package/amplify/data/schema.js +1882 -0
- package/amplify/data/schema.ts +1990 -0
- package/amplify/functions/assignment-action/handler.ts +1416 -0
- package/amplify/functions/assignment-action/resource.js +8 -0
- package/amplify/functions/assignment-action/resource.ts +9 -0
- package/amplify/functions/category-action/handler.ts +2259 -0
- package/amplify/functions/category-action/resource.js +8 -0
- package/amplify/functions/category-action/resource.ts +9 -0
- package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
- package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
- package/amplify/functions/console-chat-responder/Dockerfile +19 -0
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
- package/amplify/functions/console-chat-responder/resource.js +153 -0
- package/amplify/functions/console-chat-responder/resource.ts +179 -0
- package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/email-submission-processor/handler.py +46 -0
- package/amplify/functions/email-submission-processor/requirements.txt +2 -0
- package/amplify/functions/email-submission-processor/resource.js +56 -0
- package/amplify/functions/email-submission-processor/resource.ts +69 -0
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
- package/amplify/functions/knowledge-query/handler.py +1 -0
- package/amplify/functions/knowledge-query/requirements.txt +3 -0
- package/amplify/functions/knowledge-query/resource.js +50 -0
- package/amplify/functions/knowledge-query/resource.ts +61 -0
- package/amplify/functions/manage-user-role/handler.ts +799 -0
- package/amplify/functions/manage-user-role/resource.js +8 -0
- package/amplify/functions/manage-user-role/resource.ts +9 -0
- package/amplify/functions/model-attachment-upload/handler.ts +387 -0
- package/amplify/functions/model-attachment-upload/resource.js +8 -0
- package/amplify/functions/model-attachment-upload/resource.ts +9 -0
- package/amplify/functions/newsroom-summary/handler.ts +321 -0
- package/amplify/functions/newsroom-summary/resource.js +8 -0
- package/amplify/functions/newsroom-summary/resource.ts +9 -0
- package/amplify/functions/procedure-action/handler.ts +621 -0
- package/amplify/functions/procedure-action/resource.js +8 -0
- package/amplify/functions/procedure-action/resource.ts +9 -0
- package/amplify/functions/reader-settings/handler.ts +322 -0
- package/amplify/functions/reader-settings/resource.js +8 -0
- package/amplify/functions/reader-settings/resource.ts +9 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
- package/amplify/functions/ses-inbound-receive/resource.js +8 -0
- package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
- package/amplify/functions/shared/amplify-secrets.ts +65 -0
- package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
- package/amplify/functions/shared/email-mime-intake.ts +196 -0
- package/amplify/functions/shared/email-submission.ts +415 -0
- package/amplify/functions/shared/inbound-email-intake.ts +37 -0
- package/amplify/functions/shared/lambda-data-client.ts +21 -0
- package/amplify/functions/shared/model-payloads.ts +186 -0
- package/amplify/functions/shared/python-bundle.js +130 -0
- package/amplify/functions/shared/python-bundle.ts +162 -0
- package/amplify/functions/shared/slack-events.ts +111 -0
- package/amplify/functions/slack-delivery/handler.py +112 -0
- package/amplify/functions/slack-delivery/requirements.txt +2 -0
- package/amplify/functions/slack-delivery/resource.js +38 -0
- package/amplify/functions/slack-delivery/resource.ts +51 -0
- package/amplify/functions/slack-events/handler.ts +155 -0
- package/amplify/functions/slack-events/resource.js +11 -0
- package/amplify/functions/slack-events/resource.ts +12 -0
- package/amplify/inbound-email/stack.js +46 -0
- package/amplify/inbound-email/stack.ts +61 -0
- package/amplify/package.json +3 -0
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.js +1 -0
- package/amplify/site-backend-config.ts +36 -0
- package/amplify/site-backend.js +615 -0
- package/amplify/site-backend.ts +739 -0
- package/amplify/storage/resource.js +21 -0
- package/amplify/storage/resource.ts +22 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
- package/app/[year]/[month]/[day]/page.tsx +22 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
- package/app/api/archive/editions/route.ts +18 -0
- package/app/api/media/[...path]/route.ts +35 -0
- package/app/api/revalidate/route.ts +94 -0
- package/app/archive/page.tsx +24 -0
- package/app/articles/[slug]/page.tsx +35 -0
- package/app/globals.css +10345 -0
- package/app/layout.tsx +142 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
- package/app/newsroom/[section]/page.tsx +47 -0
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/newsroom/forum/[threadId]/page.tsx +35 -0
- package/app/newsroom/insights/[threadId]/page.tsx +22 -0
- package/app/newsroom/insights/page.tsx +14 -0
- package/app/newsroom/layout.tsx +12 -0
- package/app/newsroom/page.tsx +31 -0
- package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
- package/app/page.tsx +272 -0
- package/app/papyrus-theme.css +11 -0
- package/app/robots.ts +11 -0
- package/app/settings/page.tsx +14 -0
- package/app/tailwind.css +65 -0
- package/bin/papyrus-app.mjs +20 -0
- package/bin/papyrus-infra.mjs +95 -0
- package/components/ai-elements/conversation.tsx +168 -0
- package/components/ai-elements/message.tsx +360 -0
- package/components/ai-elements/model-selector.tsx +30 -0
- package/components/ai-elements/prompt-input.tsx +1461 -0
- package/components/ai-elements/shimmer.tsx +76 -0
- package/components/ai-elements/suggestion.tsx +54 -0
- package/components/ai-elements/tool.tsx +173 -0
- package/components/amplify-client-provider.tsx +140 -0
- package/components/archive-grid.tsx +89 -0
- package/components/archive-shell.tsx +41 -0
- package/components/article-video.tsx +156 -0
- package/components/blog-page-background.tsx +20 -0
- package/components/dev-sandbox-editor-sign-in.tsx +60 -0
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-client-provider.tsx +211 -0
- package/components/news-desk-page.tsx +116 -0
- package/components/news-desk-taxonomy-client.ts +3684 -0
- package/components/newspaper.tsx +1929 -0
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-assignments-view.tsx +664 -0
- package/components/newsroom-ops-overview.tsx +226 -0
- package/components/newsroom-ops-shell.tsx +321 -0
- package/components/newsroom-references-view.tsx +598 -0
- package/components/newsroom-topics-view.tsx +425 -0
- package/components/papyrus-console-shell.tsx +1912 -0
- package/components/pictogram-figure.tsx +65 -0
- package/components/presentation-footer.tsx +146 -0
- package/components/reader-auth-control.tsx +162 -0
- package/components/reader-auth-state.ts +122 -0
- package/components/reader-settings.ts +332 -0
- package/components/reader-theme-control.tsx +85 -0
- package/components/reference-source-preview.tsx +163 -0
- package/components/settings-page.tsx +239 -0
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +18005 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button-group.tsx +87 -0
- package/components/ui/button.tsx +58 -0
- package/components/ui/card.tsx +32 -0
- package/components/ui/command.tsx +194 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/dropdown-menu.tsx +266 -0
- package/components/ui/hover-card.tsx +51 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +16 -0
- package/components/ui/select.tsx +199 -0
- package/components/ui/separator.tsx +23 -0
- package/components/ui/sheet.tsx +92 -0
- package/components/ui/spinner.tsx +10 -0
- package/components/ui/tabs.tsx +113 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/use-resolved-papyrus-theme.ts +47 -0
- package/infra/amplify-app-shell.js +234 -0
- package/infra/amplify-app-shell.ts +284 -0
- package/infra/build-specs.js +85 -0
- package/infra/build-specs.ts +131 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +15 -0
- package/infra/site-config.js +186 -0
- package/infra/site-config.ts +235 -0
- package/lib/amplify-outputs-guard.ts +24 -0
- package/lib/amplify-outputs-path.ts +16 -0
- package/lib/amplify-server-runtime.ts +63 -0
- package/lib/archive-data.ts +30 -0
- package/lib/archive-types.ts +15 -0
- package/lib/articles.ts +320 -0
- package/lib/cached-content-repository.ts +90 -0
- package/lib/category-dashboard.ts +37 -0
- package/lib/category-repository.ts +1907 -0
- package/lib/console-chat-client.ts +1316 -0
- package/lib/console-web-context.ts +118 -0
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-load-timing.ts +23 -0
- package/lib/content-repository.ts +43 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +104 -0
- package/lib/define-site.js +3 -0
- package/lib/define-site.ts +21 -0
- package/lib/demo-amplify-outputs.ts +15 -0
- package/lib/dev-sandbox-editor-auth.ts +18 -0
- package/lib/doctrine.ts +128 -0
- package/lib/edition-routes.ts +155 -0
- package/lib/edition-sections.ts +111 -0
- package/lib/empty-edition-layout-plan.ts +109 -0
- package/lib/empty-theme.css +1 -0
- package/lib/excerpts.ts +8 -0
- package/lib/graphql-content-repository.ts +1140 -0
- package/lib/image-url.ts +10 -0
- package/lib/layout-plan.ts +981 -0
- package/lib/layout-scenarios.ts +564 -0
- package/lib/markus-body.ts +168 -0
- package/lib/markus-ir.ts +235 -0
- package/lib/markus-projection.ts +243 -0
- package/lib/markus-to-article.ts +93 -0
- package/lib/news-desk-session.ts +160 -0
- package/lib/newspaper-layout.ts +3459 -0
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-assignments.ts +173 -0
- package/lib/newsroom-base-path.ts +48 -0
- package/lib/newsroom-card-layout.ts +102 -0
- package/lib/newsroom-category-drilldown.ts +133 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-demo-dashboard.ts +267 -0
- package/lib/newsroom-demo-profile.ts +46 -0
- package/lib/newsroom-forum-routes.ts +153 -0
- package/lib/newsroom-index-filters.ts +202 -0
- package/lib/newsroom-knowledge-query-request.ts +51 -0
- package/lib/newsroom-list-selection.ts +42 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +44 -0
- package/lib/newsroom-references.ts +116 -0
- package/lib/newsroom-sections.ts +52 -0
- package/lib/newsroom-topics.ts +118 -0
- package/lib/papyrus-object-kinds.ts +24 -0
- package/lib/papyrus-web-locations.ts +480 -0
- package/lib/pilobol-sample.ts +167 -0
- package/lib/presentation-footer.ts +92 -0
- package/lib/pretext-layout.ts +179 -0
- package/lib/public-placeholder-config.ts +102 -0
- package/lib/publication-items.ts +181 -0
- package/lib/reader-route-config.ts +1 -0
- package/lib/reader-static-params.ts +56 -0
- package/lib/reader-storage-url.ts +63 -0
- package/lib/reference-display.ts +74 -0
- package/lib/reference-policy.ts +97 -0
- package/lib/reference-source-preview.ts +175 -0
- package/lib/renderer-config.ts +50 -0
- package/lib/renderer.ts +87 -0
- package/lib/reporting-story-budget.ts +638 -0
- package/lib/semantic-graph.ts +554 -0
- package/lib/site-brand-tenant.ts +22 -0
- package/lib/site-brand.ts +194 -0
- package/lib/site-env.ts +52 -0
- package/lib/site-renderer.ts +17 -0
- package/lib/site-stack.ts +195 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/themed-image.ts +30 -0
- package/lib/utils.ts +6 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +103 -0
- package/package.json +318 -1
- package/renderers/markus/index.ts +25 -0
- package/renderers/markus/stubs.tsx +33 -0
- package/renderers/pretext/article-page.tsx +169 -0
- package/renderers/pretext/index.ts +30 -0
- package/renderers/pretext/layouts.ts +16 -0
- package/renderers/pretext/presentation-shell.tsx +470 -0
- package/routes.manifest.json +208 -0
- package/scripts/fixtures/pilobol-sample.ast.json +234 -0
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/backend.js +1 -0
- package/src/backend.ts +3 -0
- package/src/sync.mjs +87 -0
- package/src/with-papyrus.d.mts +2 -0
- package/src/with-papyrus.mjs +60 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
|
|
2
|
+
import { generateClient } from "aws-amplify/data";
|
|
3
|
+
import type { Schema } from "../amplify/data/resource";
|
|
4
|
+
import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
|
|
5
|
+
import { withContentLoadTiming } from "./content-load-timing";
|
|
6
|
+
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
7
|
+
import { resolveReaderStorageUrl, signStorageUrl } from "./reader-storage-url";
|
|
8
|
+
import type { ContentRepository, EditionContent, EditionRouteSummary, ListPublishedEditionsOptions, LoadEditionContentOptions } from "./content-types";
|
|
9
|
+
import { isDemoAmplifyOutputs } from "./demo-amplify-outputs";
|
|
10
|
+
import { createEditionSectionPlan } from "./edition-sections";
|
|
11
|
+
import { normalizeEditionLayoutPlan, validateEditionLayoutPlanForItems, type EditionLayoutPlan } from "./layout-plan";
|
|
12
|
+
import {
|
|
13
|
+
articleToPublicationItem,
|
|
14
|
+
publicationItemToArticle,
|
|
15
|
+
type NonArticlePublicationItem,
|
|
16
|
+
type PublicationItem,
|
|
17
|
+
type PublicationItemType,
|
|
18
|
+
} from "./publication-items";
|
|
19
|
+
import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
|
|
20
|
+
import { SITE_BRAND } from "./site-brand";
|
|
21
|
+
import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
|
|
22
|
+
import { getContentSource } from "./site-env";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_EDITION_SLUG = "current";
|
|
25
|
+
const PUBLISHED_STATUS = "published";
|
|
26
|
+
const DRAFT_STATUS = "draft";
|
|
27
|
+
const ARTICLE_TYPE = "article";
|
|
28
|
+
|
|
29
|
+
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
30
|
+
type EditionPublishedAtIndexQuery = (
|
|
31
|
+
input: { status: string },
|
|
32
|
+
options: Record<string, unknown>,
|
|
33
|
+
) => Promise<GraphQLListResponse<GraphQLEdition>>;
|
|
34
|
+
|
|
35
|
+
type GraphQLListResponse<T> = {
|
|
36
|
+
data?: T[] | null;
|
|
37
|
+
nextToken?: string | null;
|
|
38
|
+
errors?: unknown[] | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type GraphQLGetResponse<T> = {
|
|
42
|
+
data?: T | null;
|
|
43
|
+
errors?: unknown[] | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type GraphQLEdition = {
|
|
47
|
+
id: string;
|
|
48
|
+
sourceEditionId?: string | null;
|
|
49
|
+
editionLineageId?: string | null;
|
|
50
|
+
versionNumber?: number | null;
|
|
51
|
+
slug: string;
|
|
52
|
+
title: string;
|
|
53
|
+
status: string;
|
|
54
|
+
editionDate: string;
|
|
55
|
+
publishedAt?: string | null;
|
|
56
|
+
description?: string | null;
|
|
57
|
+
layoutPlan?: unknown;
|
|
58
|
+
metadata?: unknown;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type GraphQLEditionItem = {
|
|
62
|
+
id: string;
|
|
63
|
+
publishedEditionId: string;
|
|
64
|
+
publishedItemId: string;
|
|
65
|
+
sortKey: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type GraphQLItem = {
|
|
69
|
+
id: string;
|
|
70
|
+
versionNumber?: number | null;
|
|
71
|
+
type: string;
|
|
72
|
+
status: string;
|
|
73
|
+
slug: string;
|
|
74
|
+
shortSlug?: string | null;
|
|
75
|
+
section?: string | null;
|
|
76
|
+
title?: string | null;
|
|
77
|
+
headline?: string | null;
|
|
78
|
+
deck?: string | null;
|
|
79
|
+
bodyMarkus?: string | null;
|
|
80
|
+
bodyIr?: unknown;
|
|
81
|
+
aliases?: Array<string | null> | null;
|
|
82
|
+
metadata?: unknown;
|
|
83
|
+
byline?: string | null;
|
|
84
|
+
dateline?: string | null;
|
|
85
|
+
publishedAt?: string | null;
|
|
86
|
+
pullQuotes?: Array<string | null> | null;
|
|
87
|
+
editorial?: unknown;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type GraphQLMediaAsset = {
|
|
91
|
+
id: string;
|
|
92
|
+
publishedItemId: string;
|
|
93
|
+
sourceItemId?: string | null;
|
|
94
|
+
itemLineageId?: string | null;
|
|
95
|
+
type: string;
|
|
96
|
+
role: string;
|
|
97
|
+
sortKey: string;
|
|
98
|
+
storagePath?: string | null;
|
|
99
|
+
externalUrl?: string | null;
|
|
100
|
+
alt?: string | null;
|
|
101
|
+
caption?: string | null;
|
|
102
|
+
credit?: string | null;
|
|
103
|
+
width?: number | null;
|
|
104
|
+
height?: number | null;
|
|
105
|
+
aspectRatio?: number | null;
|
|
106
|
+
focalX?: number | null;
|
|
107
|
+
focalY?: number | null;
|
|
108
|
+
minHeight?: number | null;
|
|
109
|
+
preferredHeight?: number | null;
|
|
110
|
+
maxHeight?: number | null;
|
|
111
|
+
crop?: string | null;
|
|
112
|
+
wrapsText?: boolean | null;
|
|
113
|
+
metadata?: unknown;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type ContentAttachmentPointer = {
|
|
117
|
+
storagePath?: string | null;
|
|
118
|
+
mediaType?: string | null;
|
|
119
|
+
role?: string | null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type ResolvedItemContent = {
|
|
123
|
+
excerpt: string | null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
|
|
127
|
+
"lead",
|
|
128
|
+
"continuation",
|
|
129
|
+
"continuationInset",
|
|
130
|
+
"feature",
|
|
131
|
+
"thumbnail",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
let readerContextClient: DataClient | null = null;
|
|
135
|
+
let buildTimeClient: DataClient | null = null;
|
|
136
|
+
|
|
137
|
+
function authMode(): "userPool" | "identityPool" {
|
|
138
|
+
return currentContentSource() === "drafts" ? "userPool" : "identityPool";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isVisibleItemStatus(status: string): boolean {
|
|
142
|
+
if (status === PUBLISHED_STATUS) return true;
|
|
143
|
+
return currentContentSource() === "drafts" && status === DRAFT_STATUS;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
147
|
+
const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
|
|
148
|
+
const source = getContentSource();
|
|
149
|
+
if (source === "drafts") {
|
|
150
|
+
// Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
|
|
151
|
+
// kept in the request's async context (never a module global) so one visitor's session cannot serve another.
|
|
152
|
+
const { cookies } = await import("next/headers");
|
|
153
|
+
const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
|
|
154
|
+
return runWithContentSource(source, operation, client);
|
|
155
|
+
}
|
|
156
|
+
return runWithContentSource(source, () =>
|
|
157
|
+
runWithAmplifyServerContext({
|
|
158
|
+
nextServerContext: null,
|
|
159
|
+
operation: async () => {
|
|
160
|
+
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
161
|
+
try {
|
|
162
|
+
return await operation();
|
|
163
|
+
} finally {
|
|
164
|
+
readerContextClient = null;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const graphqlContentRepository: ContentRepository = {
|
|
172
|
+
loadEditionContent(options) {
|
|
173
|
+
return withReaderGraphQLContext(() =>
|
|
174
|
+
withContentLoadTiming("loadEditionContent", { editionDate: options?.editionDate ?? null }, async () => {
|
|
175
|
+
const edition = options?.editionDate
|
|
176
|
+
? await loadPublishedEditionForDate(options.editionDate, options.editionSlug)
|
|
177
|
+
: await loadActiveEdition();
|
|
178
|
+
return loadEditionContentFromEdition(edition);
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
getLatestPublishedEdition() {
|
|
184
|
+
if (isDemoAmplifyOutputs()) return null;
|
|
185
|
+
return withReaderGraphQLContext(async () => {
|
|
186
|
+
try {
|
|
187
|
+
return summarizeEditionRoute(await loadLatestPublishedEdition());
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (isMissingReaderBackendError(error)) return null;
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
getFirstPublishedEdition() {
|
|
196
|
+
if (isDemoAmplifyOutputs()) return null;
|
|
197
|
+
return withReaderGraphQLContext(async () => {
|
|
198
|
+
try {
|
|
199
|
+
return summarizeEditionRoute(await loadFirstPublishedEdition());
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (isMissingReaderBackendError(error)) return null;
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
listPublishedEditions(options) {
|
|
208
|
+
if (isDemoAmplifyOutputs()) {
|
|
209
|
+
return { editions: [], nextToken: undefined };
|
|
210
|
+
}
|
|
211
|
+
return withReaderGraphQLContext(async () => {
|
|
212
|
+
const result = await listPublishedEditionSummaries(options);
|
|
213
|
+
return {
|
|
214
|
+
editions: result.editions.map(summarizeEditionRoute),
|
|
215
|
+
nextToken: result.nextToken,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
getArticle(slug) {
|
|
221
|
+
return withReaderGraphQLContext(async () => {
|
|
222
|
+
const item = await getItemBySlug(slug);
|
|
223
|
+
if (!item || item.type !== ARTICLE_TYPE || !isVisibleItemStatus(item.status)) return undefined;
|
|
224
|
+
return normalizeArticle(item, await listMediaAssets(item.id));
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
getEditionArticle({ editionDate, articleSlug }) {
|
|
229
|
+
return withReaderGraphQLContext(async () => {
|
|
230
|
+
const item = await loadEditionItem(editionDate, articleSlug);
|
|
231
|
+
return item ? publicationItemToArticle(item) : undefined;
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
getEditionItem({ editionDate, itemSlug }) {
|
|
236
|
+
return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
listArticleSlugs() {
|
|
240
|
+
return withReaderGraphQLContext(async () => {
|
|
241
|
+
const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
|
|
242
|
+
return items
|
|
243
|
+
.filter((item) => item.type === ARTICLE_TYPE && isVisibleItemStatus(item.status))
|
|
244
|
+
.map((item) => item.slug)
|
|
245
|
+
.sort();
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
function getClient(): DataClient {
|
|
251
|
+
const requestClient = currentRequestClient<DataClient>();
|
|
252
|
+
if (requestClient) return requestClient;
|
|
253
|
+
if (readerContextClient) return readerContextClient;
|
|
254
|
+
|
|
255
|
+
if (!buildTimeClient) {
|
|
256
|
+
getAmplifyServerRuntime();
|
|
257
|
+
buildTimeClient = generateClient<Schema>({ authMode: authMode() });
|
|
258
|
+
}
|
|
259
|
+
return buildTimeClient;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function loadActiveEdition(): Promise<GraphQLEdition> {
|
|
263
|
+
const configuredSlug = process.env.PAPYRUS_EDITION_SLUG ?? DEFAULT_EDITION_SLUG;
|
|
264
|
+
const bySlug = await listAll<GraphQLEdition>((options) =>
|
|
265
|
+
getPublishedEditionModel().publishedEditionBySlug({ slug: configuredSlug }, options),
|
|
266
|
+
);
|
|
267
|
+
if (bySlug[0]) return bySlug[0];
|
|
268
|
+
|
|
269
|
+
return loadLatestPublishedEdition();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function loadLatestPublishedEdition(): Promise<GraphQLEdition> {
|
|
273
|
+
const listByPublishedAt = getEditionPublishedAtIndexQuery();
|
|
274
|
+
if (listByPublishedAt) {
|
|
275
|
+
try {
|
|
276
|
+
const [latestByPublishedAt] = await listFirst<GraphQLEdition>((options) =>
|
|
277
|
+
listByPublishedAt({ status: PUBLISHED_STATUS }, options),
|
|
278
|
+
);
|
|
279
|
+
if (latestByPublishedAt) return latestByPublishedAt;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (!isMissingPublishedAtIndexError(error)) throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const [latestByEditionDate] = await listFirst<GraphQLEdition>((options) =>
|
|
286
|
+
getPublishedEditionModel().listPublishedEditionsByStatusAndEditionDate({ status: PUBLISHED_STATUS }, options),
|
|
287
|
+
);
|
|
288
|
+
if (latestByEditionDate) return latestByEditionDate;
|
|
289
|
+
|
|
290
|
+
throw new Error("No published GraphQL edition found. Seed the Amplify sandbox or set PAPYRUS_EDITION_SLUG.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function loadFirstPublishedEdition(): Promise<GraphQLEdition> {
|
|
294
|
+
const editions = await listAll<GraphQLEdition>((options) =>
|
|
295
|
+
getPublishedEditionModel().listPublishedEditionsByStatusAndEditionDate({ status: PUBLISHED_STATUS }, options),
|
|
296
|
+
);
|
|
297
|
+
const [firstEdition] = editions.sort(compareEditionsByOldest);
|
|
298
|
+
if (firstEdition) return firstEdition;
|
|
299
|
+
throw new Error("No published GraphQL edition found. Seed the Amplify sandbox or set PAPYRUS_EDITION_SLUG.");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function listPublishedEditionSummaries({
|
|
303
|
+
limit = 12,
|
|
304
|
+
nextToken,
|
|
305
|
+
}: ListPublishedEditionsOptions = {}): Promise<{ editions: GraphQLEdition[]; nextToken?: string | null }> {
|
|
306
|
+
const safeLimit = clampEditionPageLimit(limit);
|
|
307
|
+
const listByPublishedAt = getEditionPublishedAtIndexQuery();
|
|
308
|
+
if (listByPublishedAt) {
|
|
309
|
+
try {
|
|
310
|
+
return await listPage<GraphQLEdition>(
|
|
311
|
+
(options) => listByPublishedAt({ status: PUBLISHED_STATUS }, options),
|
|
312
|
+
safeLimit,
|
|
313
|
+
nextToken,
|
|
314
|
+
);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (!isMissingPublishedAtIndexError(error)) throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return listPage<GraphQLEdition>(
|
|
321
|
+
(options) => getPublishedEditionModel().listPublishedEditionsByStatusAndEditionDate({ status: PUBLISHED_STATUS }, options),
|
|
322
|
+
safeLimit,
|
|
323
|
+
nextToken,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function loadPublishedEditionForDate(editionDate: string, editionSlug?: string | null): Promise<GraphQLEdition> {
|
|
328
|
+
const editions = await listPublishedEditionsForDate(editionDate);
|
|
329
|
+
const selectedEdition = editionSlug ? editions.find((edition) => edition.slug === editionSlug) : editions[0];
|
|
330
|
+
if (!selectedEdition) {
|
|
331
|
+
const slugDetail = editionSlug ? ` and slug ${editionSlug}` : "";
|
|
332
|
+
throw new Error(`No published GraphQL edition found for date ${editionDate}${slugDetail}.`);
|
|
333
|
+
}
|
|
334
|
+
return selectedEdition;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function listPublishedEditionsForDate(editionDate: string): Promise<GraphQLEdition[]> {
|
|
338
|
+
const editions = await listAll<GraphQLEdition>((options) =>
|
|
339
|
+
getPublishedEditionModel().listPublishedEditionsByStatusAndEditionDate(
|
|
340
|
+
{ status: PUBLISHED_STATUS, editionDate: { eq: editionDate } },
|
|
341
|
+
options,
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
return editions.sort(compareEditionsByFreshness);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<EditionContent> {
|
|
348
|
+
const layoutPlan = normalizeEditionLayoutPlan(edition.layoutPlan, "Edition.layoutPlan");
|
|
349
|
+
const editionItems = await listEditionItems(edition.id);
|
|
350
|
+
const editionMetadata = parseObjectMetadata(edition.metadata);
|
|
351
|
+
const itemsBySlug = new Map<string, PublicationItem>();
|
|
352
|
+
const normalizedEditionItems = (
|
|
353
|
+
await Promise.all(
|
|
354
|
+
editionItems.map(async (editionItem) => {
|
|
355
|
+
const item = await getItemById(editionItem.publishedItemId);
|
|
356
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
357
|
+
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
358
|
+
}),
|
|
359
|
+
)
|
|
360
|
+
).filter((item): item is PublicationItem => item !== null);
|
|
361
|
+
for (const item of normalizedEditionItems) itemsBySlug.set(item.slug, item);
|
|
362
|
+
|
|
363
|
+
const missingLayoutItems = [...collectLayoutPlanItemIds(layoutPlan)].filter((itemId) => !itemsBySlug.has(itemId));
|
|
364
|
+
if (missingLayoutItems.length > 0) {
|
|
365
|
+
const recoveredItems = (
|
|
366
|
+
await Promise.all(
|
|
367
|
+
missingLayoutItems.map(async (itemId) => {
|
|
368
|
+
const item = await getItemBySlug(itemId);
|
|
369
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
370
|
+
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
).filter((item): item is PublicationItem => item !== null);
|
|
374
|
+
for (const item of recoveredItems) itemsBySlug.set(item.slug, item);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const items = [...itemsBySlug.values()];
|
|
378
|
+
const availableItemSlugs = new Set(items.map((item) => item.slug));
|
|
379
|
+
const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
|
|
380
|
+
validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
id: edition.id,
|
|
384
|
+
source: "graphql",
|
|
385
|
+
title: edition.title,
|
|
386
|
+
editionDate: edition.editionDate,
|
|
387
|
+
description: edition.description ?? "GraphQL content loaded from Amplify Data.",
|
|
388
|
+
layoutPlan: sanitizedLayoutPlan,
|
|
389
|
+
items,
|
|
390
|
+
sections: createEditionSectionPlan(items, edition.metadata),
|
|
391
|
+
suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
|
|
392
|
+
editionVideo: await normalizeEditionVideoAsset(editionMetadata?.editionVideo),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
|
|
397
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
398
|
+
const record = value as Record<string, unknown>;
|
|
399
|
+
const srcPath = typeof record.src === "string" ? record.src.trim() : "";
|
|
400
|
+
if (!srcPath) return null;
|
|
401
|
+
const storagePath = typeof record.storagePath === "string" ? record.storagePath.trim() : "";
|
|
402
|
+
const src = storagePath ? await resolveReaderStorageUrl(storagePath) : srcPath;
|
|
403
|
+
if (!src) return null;
|
|
404
|
+
const posterSrc = typeof record.posterSrc === "string" && record.posterSrc.trim() ? record.posterSrc.trim() : undefined;
|
|
405
|
+
const durationSeconds =
|
|
406
|
+
typeof record.durationSeconds === "number" && Number.isFinite(record.durationSeconds)
|
|
407
|
+
? record.durationSeconds
|
|
408
|
+
: undefined;
|
|
409
|
+
const alt = typeof record.alt === "string" && record.alt.trim() ? record.alt.trim() : "Edition overview video";
|
|
410
|
+
const caption = typeof record.caption === "string" ? record.caption.trim() : undefined;
|
|
411
|
+
const credit = typeof record.credit === "string" && record.credit.trim() ? record.credit.trim() : (SITE_BRAND.defaultVideoCredit ?? "Edition overview video");
|
|
412
|
+
const themeVariants = await parseVideoThemeVariantsMetadata(record.themeVariants);
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
type: "video",
|
|
416
|
+
src,
|
|
417
|
+
posterSrc,
|
|
418
|
+
alt,
|
|
419
|
+
caption: caption || undefined,
|
|
420
|
+
credit,
|
|
421
|
+
durationSeconds,
|
|
422
|
+
roles: ["feature"],
|
|
423
|
+
themeVariants,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function pruneLayoutPlanUnavailableItems(
|
|
428
|
+
layoutPlan: EditionLayoutPlan,
|
|
429
|
+
availableItemSlugs: Set<string>,
|
|
430
|
+
editionId: string,
|
|
431
|
+
): EditionLayoutPlan {
|
|
432
|
+
let removedBlocks = 0;
|
|
433
|
+
const pages = layoutPlan.pages.map((page) => ({
|
|
434
|
+
...page,
|
|
435
|
+
regions: page.regions.map((region) => ({
|
|
436
|
+
...region,
|
|
437
|
+
blocks: region.blocks.filter((block) => {
|
|
438
|
+
if ("itemId" in block && typeof block.itemId === "string" && block.itemId.trim()) {
|
|
439
|
+
if (!availableItemSlugs.has(block.itemId)) {
|
|
440
|
+
removedBlocks += 1;
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if ("itemIds" in block && Array.isArray(block.itemIds)) {
|
|
445
|
+
for (const itemId of block.itemIds) {
|
|
446
|
+
if (typeof itemId === "string" && itemId.trim() && !availableItemSlugs.has(itemId)) {
|
|
447
|
+
removedBlocks += 1;
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return true;
|
|
453
|
+
}),
|
|
454
|
+
})),
|
|
455
|
+
}));
|
|
456
|
+
if (removedBlocks > 0) {
|
|
457
|
+
console.warn(
|
|
458
|
+
`[graphql-content-repository] Pruned ${removedBlocks} layout block(s) that referenced unavailable published items in edition ${editionId}.`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return { ...layoutPlan, pages };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function collectLayoutPlanItemIds(layoutPlan: EditionLayoutPlan): Set<string> {
|
|
465
|
+
const ids = new Set<string>();
|
|
466
|
+
for (const page of layoutPlan.pages) {
|
|
467
|
+
for (const region of page.regions) {
|
|
468
|
+
for (const block of region.blocks) {
|
|
469
|
+
if ("itemId" in block && typeof block.itemId === "string" && block.itemId.trim()) ids.add(block.itemId);
|
|
470
|
+
if ("itemIds" in block && Array.isArray(block.itemIds)) {
|
|
471
|
+
for (const itemId of block.itemIds) {
|
|
472
|
+
if (typeof itemId === "string" && itemId.trim()) ids.add(itemId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return ids;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function loadEditionItem(editionDate: string, itemSlug: string): Promise<PublicationItem | undefined> {
|
|
482
|
+
const edition = await loadPublishedEditionForDate(editionDate);
|
|
483
|
+
const editionItems = await listEditionItems(edition.id);
|
|
484
|
+
for (const editionItem of editionItems) {
|
|
485
|
+
const item = await getItemById(editionItem.publishedItemId);
|
|
486
|
+
if (!item || item.slug !== itemSlug || !isVisibleItemStatus(item.status)) continue;
|
|
487
|
+
return (await normalizePublicationItem(item, await listMediaAssets(item.id))) ?? undefined;
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function summarizeEditionRoute(edition: GraphQLEdition): EditionRouteSummary {
|
|
493
|
+
return {
|
|
494
|
+
id: edition.id,
|
|
495
|
+
slug: edition.slug,
|
|
496
|
+
title: edition.title,
|
|
497
|
+
editionDate: edition.editionDate,
|
|
498
|
+
publishedAt: edition.publishedAt,
|
|
499
|
+
description: edition.description ?? null,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function compareEditionsByFreshness(left: GraphQLEdition, right: GraphQLEdition): number {
|
|
504
|
+
const leftPublishedAt = left.publishedAt ?? `${left.editionDate}T00:00:00.000Z`;
|
|
505
|
+
const rightPublishedAt = right.publishedAt ?? `${right.editionDate}T00:00:00.000Z`;
|
|
506
|
+
return rightPublishedAt.localeCompare(leftPublishedAt) || right.id.localeCompare(left.id);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function compareEditionsByOldest(left: GraphQLEdition, right: GraphQLEdition): number {
|
|
510
|
+
const leftPublishedAt = left.publishedAt ?? `${left.editionDate}T00:00:00.000Z`;
|
|
511
|
+
const rightPublishedAt = right.publishedAt ?? `${right.editionDate}T00:00:00.000Z`;
|
|
512
|
+
return left.editionDate.localeCompare(right.editionDate)
|
|
513
|
+
|| leftPublishedAt.localeCompare(rightPublishedAt)
|
|
514
|
+
|| left.id.localeCompare(right.id);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function isMissingGraphQLEditionError(error: unknown): boolean {
|
|
518
|
+
return error instanceof Error && error.message.includes("No published GraphQL edition found");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function isMissingProjectionModelError(error: unknown): boolean {
|
|
522
|
+
return error instanceof Error && error.message.includes("missing projection model");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function isMissingReaderBackendError(error: unknown): boolean {
|
|
526
|
+
return isMissingGraphQLEditionError(error) || isMissingProjectionModelError(error);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function getEditionPublishedAtIndexQuery(): EditionPublishedAtIndexQuery | null {
|
|
530
|
+
const editionModel = getPublishedEditionModel();
|
|
531
|
+
return typeof editionModel.listPublishedEditionsByStatusAndPublishedAt === "function"
|
|
532
|
+
? editionModel.listPublishedEditionsByStatusAndPublishedAt
|
|
533
|
+
: null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
type ListOptions = Record<string, unknown>;
|
|
537
|
+
type ListResponse<T> = Promise<GraphQLListResponse<T>>;
|
|
538
|
+
|
|
539
|
+
type EditionSourceModel = {
|
|
540
|
+
publishedEditionBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLEdition>;
|
|
541
|
+
listPublishedEditionsByStatusAndEditionDate: (
|
|
542
|
+
input: { status: string; editionDate?: { eq: string } },
|
|
543
|
+
options: ListOptions,
|
|
544
|
+
) => ListResponse<GraphQLEdition>;
|
|
545
|
+
listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
type EditionItemSourceModel = {
|
|
549
|
+
listPublishedEditionItemsByEditionAndSortKey: (
|
|
550
|
+
input: { publishedEditionId: string },
|
|
551
|
+
options: ListOptions,
|
|
552
|
+
) => ListResponse<GraphQLEditionItem>;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
type ItemSourceModel = {
|
|
556
|
+
get: (input: { id: string }, options: ListOptions) => Promise<GraphQLGetResponse<GraphQLItem>>;
|
|
557
|
+
publishedItemBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
558
|
+
listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
type MediaAssetSourceModel = {
|
|
562
|
+
listPublishedMediaAssetsByItemAndSortKey: (
|
|
563
|
+
input: { publishedItemId: string },
|
|
564
|
+
options: ListOptions,
|
|
565
|
+
) => ListResponse<GraphQLMediaAsset>;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const DRAFTS_EDITION_STATUSES = [PUBLISHED_STATUS, DRAFT_STATUS];
|
|
569
|
+
|
|
570
|
+
async function listMergedAcrossStatuses<T>(
|
|
571
|
+
statuses: string[],
|
|
572
|
+
runForStatus: (status: string, pageOptions: ListOptions) => ListResponse<T>,
|
|
573
|
+
sortValue: (row: T) => string,
|
|
574
|
+
options: ListOptions,
|
|
575
|
+
): ListResponse<T> {
|
|
576
|
+
const rows: T[] = [];
|
|
577
|
+
for (const status of statuses) {
|
|
578
|
+
let pageToken: string | null | undefined;
|
|
579
|
+
do {
|
|
580
|
+
const response = await runForStatus(status, { authMode: options.authMode, limit: 100, nextToken: pageToken });
|
|
581
|
+
if (response.errors?.length) return { data: [], errors: response.errors };
|
|
582
|
+
rows.push(...((response.data ?? []).filter(Boolean) as T[]));
|
|
583
|
+
pageToken = response.nextToken;
|
|
584
|
+
} while (pageToken);
|
|
585
|
+
}
|
|
586
|
+
const direction = options.sortDirection === "DESC" ? -1 : 1;
|
|
587
|
+
rows.sort((left, right) => direction * sortValue(left).localeCompare(sortValue(right)));
|
|
588
|
+
const offset = Number(options.nextToken ?? 0) || 0;
|
|
589
|
+
const limit = typeof options.limit === "number" ? options.limit : undefined;
|
|
590
|
+
const end = limit === undefined ? rows.length : offset + limit;
|
|
591
|
+
return {
|
|
592
|
+
data: rows.slice(offset, end),
|
|
593
|
+
nextToken: end < rows.length ? String(end) : undefined,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function getPublishedEditionModel(): EditionSourceModel {
|
|
598
|
+
if (currentContentSource() === "drafts") {
|
|
599
|
+
const model = getClient().models.Edition;
|
|
600
|
+
if (!model) throw missingProjectionModelError("Edition");
|
|
601
|
+
return {
|
|
602
|
+
publishedEditionBySlug: (input, options) => model.editionBySlug(input, options) as ListResponse<GraphQLEdition>,
|
|
603
|
+
listPublishedEditionsByStatusAndEditionDate: (input, options) =>
|
|
604
|
+
listMergedAcrossStatuses<GraphQLEdition>(
|
|
605
|
+
DRAFTS_EDITION_STATUSES,
|
|
606
|
+
(status, pageOptions) =>
|
|
607
|
+
model.listEditionsByStatusAndEditionDate({ ...input, status }, pageOptions) as ListResponse<GraphQLEdition>,
|
|
608
|
+
(edition) => edition.editionDate,
|
|
609
|
+
options,
|
|
610
|
+
),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const model = getClient().models.PublishedEdition;
|
|
614
|
+
if (!model) throw missingProjectionModelError("PublishedEdition");
|
|
615
|
+
return model as unknown as EditionSourceModel;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getPublishedEditionItemModel(): EditionItemSourceModel {
|
|
619
|
+
if (currentContentSource() === "drafts") {
|
|
620
|
+
const model = getClient().models.EditionItem;
|
|
621
|
+
if (!model) throw missingProjectionModelError("EditionItem");
|
|
622
|
+
return {
|
|
623
|
+
listPublishedEditionItemsByEditionAndSortKey: async ({ publishedEditionId }, options) => {
|
|
624
|
+
const response = (await model.listEditionItemsByEditionAndSortKey(
|
|
625
|
+
{ editionId: publishedEditionId },
|
|
626
|
+
options,
|
|
627
|
+
)) as unknown as GraphQLListResponse<{ id: string; editionId: string; itemId: string; sortKey: string }>;
|
|
628
|
+
return {
|
|
629
|
+
...response,
|
|
630
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({
|
|
631
|
+
id: row.id,
|
|
632
|
+
publishedEditionId: row.editionId,
|
|
633
|
+
publishedItemId: row.itemId,
|
|
634
|
+
sortKey: row.sortKey,
|
|
635
|
+
})),
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const model = getClient().models.PublishedEditionItem;
|
|
641
|
+
if (!model) throw missingProjectionModelError("PublishedEditionItem");
|
|
642
|
+
return model as unknown as EditionItemSourceModel;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function getPublishedItemModel(): ItemSourceModel {
|
|
646
|
+
if (currentContentSource() === "drafts") {
|
|
647
|
+
const model = getClient().models.Item;
|
|
648
|
+
if (!model) throw missingProjectionModelError("Item");
|
|
649
|
+
return {
|
|
650
|
+
get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
|
|
651
|
+
publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
|
|
652
|
+
listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
|
|
653
|
+
const itemType = typeStatus.split("#")[0];
|
|
654
|
+
return listMergedAcrossStatuses<GraphQLItem>(
|
|
655
|
+
[DRAFT_STATUS, PUBLISHED_STATUS],
|
|
656
|
+
(status, pageOptions) =>
|
|
657
|
+
model.listItemsByTypeStatusAndPublishedAt(
|
|
658
|
+
{ typeStatus: `${itemType}#${status}` },
|
|
659
|
+
pageOptions,
|
|
660
|
+
) as ListResponse<GraphQLItem>,
|
|
661
|
+
(item) => item.publishedAt ?? "",
|
|
662
|
+
options,
|
|
663
|
+
);
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const model = getClient().models.PublishedItem;
|
|
668
|
+
if (!model) throw missingProjectionModelError("PublishedItem");
|
|
669
|
+
return model as unknown as ItemSourceModel;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function getPublishedMediaAssetModel(): MediaAssetSourceModel {
|
|
673
|
+
if (currentContentSource() === "drafts") {
|
|
674
|
+
const model = getClient().models.MediaAsset;
|
|
675
|
+
if (!model) throw missingProjectionModelError("MediaAsset");
|
|
676
|
+
return {
|
|
677
|
+
listPublishedMediaAssetsByItemAndSortKey: async ({ publishedItemId }, options) => {
|
|
678
|
+
const response = (await model.listMediaAssetsByItemAndSortKey(
|
|
679
|
+
{ itemId: publishedItemId },
|
|
680
|
+
options,
|
|
681
|
+
)) as unknown as GraphQLListResponse<Omit<GraphQLMediaAsset, "publishedItemId"> & { itemId: string }>;
|
|
682
|
+
return {
|
|
683
|
+
...response,
|
|
684
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({ ...row, publishedItemId: row.itemId })),
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
const model = getClient().models.PublishedMediaAsset;
|
|
690
|
+
if (!model) throw missingProjectionModelError("PublishedMediaAsset");
|
|
691
|
+
return model as unknown as MediaAssetSourceModel;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function missingProjectionModelError(modelName: string): Error {
|
|
695
|
+
return new Error(
|
|
696
|
+
`Amplify output is missing projection model ${modelName}. Refresh amplify_outputs.json from the latest deployed backend or regenerate it from the current sandbox schema.`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function isMissingPublishedAtIndexError(error: unknown): boolean {
|
|
701
|
+
return error instanceof Error && error.message.includes("listPublishedEditionsByStatusAndPublishedAt");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function listEditionItems(editionId: string): Promise<GraphQLEditionItem[]> {
|
|
705
|
+
const items = await listAll<GraphQLEditionItem>((options) =>
|
|
706
|
+
getPublishedEditionItemModel().listPublishedEditionItemsByEditionAndSortKey({ publishedEditionId: editionId }, options),
|
|
707
|
+
);
|
|
708
|
+
return items.sort((left, right) => left.sortKey.localeCompare(right.sortKey));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function listItemsByTypeStatus(typeStatus: string): Promise<GraphQLItem[]> {
|
|
712
|
+
return listAll<GraphQLItem>((options) =>
|
|
713
|
+
getPublishedItemModel().listPublishedItemsByTypeStatusAndPublishedAt({ typeStatus }, options),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function getItemById(id: string): Promise<GraphQLItem | null> {
|
|
718
|
+
const response = await getPublishedItemModel().get({ id }, { authMode: authMode() });
|
|
719
|
+
return readGetResponse<GraphQLItem>(response);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function getItemBySlug(slug: string): Promise<GraphQLItem | null> {
|
|
723
|
+
const items = await listAll<GraphQLItem>((options) => getPublishedItemModel().publishedItemBySlug({ slug }, options));
|
|
724
|
+
return items[0] ?? null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function listMediaAssets(itemId: string): Promise<GraphQLMediaAsset[]> {
|
|
728
|
+
const mediaAssets = await listAll<GraphQLMediaAsset>((options) =>
|
|
729
|
+
getPublishedMediaAssetModel().listPublishedMediaAssetsByItemAndSortKey({ publishedItemId: itemId }, options),
|
|
730
|
+
);
|
|
731
|
+
return mediaAssets.sort((left, right) => left.sortKey.localeCompare(right.sortKey));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function listAll<T>(operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>): Promise<T[]> {
|
|
735
|
+
const items: T[] = [];
|
|
736
|
+
let nextToken: string | null | undefined;
|
|
737
|
+
|
|
738
|
+
do {
|
|
739
|
+
const response = await operation({
|
|
740
|
+
authMode: authMode(),
|
|
741
|
+
limit: 100,
|
|
742
|
+
nextToken,
|
|
743
|
+
});
|
|
744
|
+
assertNoGraphQLErrors(response.errors);
|
|
745
|
+
items.push(...((response.data ?? []).filter(Boolean) as T[]));
|
|
746
|
+
nextToken = response.nextToken;
|
|
747
|
+
} while (nextToken);
|
|
748
|
+
|
|
749
|
+
return items;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function listFirst<T>(operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>): Promise<T[]> {
|
|
753
|
+
const response = await operation({
|
|
754
|
+
authMode: authMode(),
|
|
755
|
+
limit: 1,
|
|
756
|
+
sortDirection: "DESC",
|
|
757
|
+
});
|
|
758
|
+
assertNoGraphQLErrors(response.errors);
|
|
759
|
+
return (response.data ?? []).filter(Boolean) as T[];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function listPage<T>(
|
|
763
|
+
operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>,
|
|
764
|
+
limit: number,
|
|
765
|
+
nextToken?: string | null,
|
|
766
|
+
): Promise<{ editions: T[]; nextToken?: string | null }> {
|
|
767
|
+
const response = await operation({
|
|
768
|
+
authMode: authMode(),
|
|
769
|
+
limit,
|
|
770
|
+
nextToken,
|
|
771
|
+
sortDirection: "DESC",
|
|
772
|
+
});
|
|
773
|
+
assertNoGraphQLErrors(response.errors);
|
|
774
|
+
return {
|
|
775
|
+
editions: (response.data ?? []).filter(Boolean) as T[],
|
|
776
|
+
nextToken: response.nextToken,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function clampEditionPageLimit(limit: number): number {
|
|
781
|
+
if (!Number.isFinite(limit)) return 12;
|
|
782
|
+
return Math.max(1, Math.min(12, Math.floor(limit)));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function readGetResponse<T>(response: GraphQLGetResponse<T>): T | null {
|
|
786
|
+
assertNoGraphQLErrors(response.errors);
|
|
787
|
+
return response.data ?? null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function normalizeArticle(item: GraphQLItem, mediaAssets: GraphQLMediaAsset[]): Promise<Article> {
|
|
791
|
+
const resolvedContent = await resolveItemContentFromAttachments(item.editorial);
|
|
792
|
+
const bodyProjection = projectPublishedItemBody(item);
|
|
793
|
+
const videoAssets = mediaAssets.filter((asset) => asset.type === "video");
|
|
794
|
+
const assets = await normalizeItemImageAssets(item, mediaAssets, bodyProjection.imageSrcs);
|
|
795
|
+
const primaryImage = assets.find((asset) => asset.roles?.includes("lead")) ?? assets[0];
|
|
796
|
+
const normalizedVideos = (
|
|
797
|
+
await Promise.all(videoAssets.map((asset) => normalizeVideoAsset(item, asset, primaryImage)))
|
|
798
|
+
).filter((asset): asset is ArticleVideoAsset => asset !== null);
|
|
799
|
+
const leadVideo = normalizedVideos.find((asset) => asset.roles?.includes("lead")) ?? normalizedVideos[0];
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
slug: item.slug,
|
|
803
|
+
shortSlug: normalizeShortSlug(item.shortSlug),
|
|
804
|
+
section: item.section ?? "News",
|
|
805
|
+
headline: item.headline ?? item.title ?? item.slug,
|
|
806
|
+
deck: item.deck ?? "",
|
|
807
|
+
excerpt: resolvedContent.excerpt ?? undefined,
|
|
808
|
+
byline: item.byline ?? SITE_BRAND.placeholderByline,
|
|
809
|
+
dateline: item.dateline ?? "NEWSROOM",
|
|
810
|
+
image: primaryImage,
|
|
811
|
+
video: leadVideo,
|
|
812
|
+
assets: [...assets, ...normalizedVideos],
|
|
813
|
+
pullQuotes: bodyProjection.pullQuotes,
|
|
814
|
+
body: bodyProjection.body,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function projectPublishedItemBody(item: GraphQLItem): BodyProjection {
|
|
819
|
+
if (item.bodyIr === null || item.bodyIr === undefined || item.bodyIr === "") {
|
|
820
|
+
throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus ops content convert-bodies`);
|
|
821
|
+
}
|
|
822
|
+
return projectBodyIr(item.bodyIr);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function normalizeItemImageAssets(
|
|
826
|
+
item: GraphQLItem,
|
|
827
|
+
mediaAssets: GraphQLMediaAsset[],
|
|
828
|
+
bodyImages: BodyProjectionImage[],
|
|
829
|
+
): Promise<ArticleImageAsset[]> {
|
|
830
|
+
const imageAssets = mediaAssets.filter((asset) => asset.type === "image");
|
|
831
|
+
const matches = matchBodyImages(
|
|
832
|
+
bodyImages,
|
|
833
|
+
imageAssets.map((asset) => {
|
|
834
|
+
const srcPath = parseObjectMetadata(asset.metadata)?.srcPath;
|
|
835
|
+
return typeof srcPath === "string" ? srcPath : null;
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
const normalizedMedia = (
|
|
839
|
+
await Promise.all(imageAssets.map((asset) => normalizeImageAsset(item, asset)))
|
|
840
|
+
).filter((asset): asset is ArticleImageAsset => asset !== null);
|
|
841
|
+
const externalImages = matches.flatMap((match, index): ArticleImageAsset[] =>
|
|
842
|
+
match.kind === "external"
|
|
843
|
+
? [
|
|
844
|
+
{
|
|
845
|
+
id: `${item.id}-body-image-${index}`,
|
|
846
|
+
type: "image",
|
|
847
|
+
src: match.image.src,
|
|
848
|
+
alt: match.image.alt,
|
|
849
|
+
caption: match.image.caption,
|
|
850
|
+
credit: match.image.credit ?? match.image.caption ?? "Media asset",
|
|
851
|
+
},
|
|
852
|
+
]
|
|
853
|
+
: [],
|
|
854
|
+
);
|
|
855
|
+
return [...normalizedMedia, ...externalImages];
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function normalizePublicationItem(item: GraphQLItem, mediaAssets: GraphQLMediaAsset[]): Promise<PublicationItem | null> {
|
|
859
|
+
if (item.type === "article") return articleToPublicationItem(await normalizeArticle(item, mediaAssets));
|
|
860
|
+
const type = normalizePublicationItemType(item.type);
|
|
861
|
+
if (!type) return null;
|
|
862
|
+
const resolvedContent = await resolveItemContentFromAttachments(item.editorial);
|
|
863
|
+
const bodyProjection = projectPublishedItemBody(item);
|
|
864
|
+
const videoAssets = mediaAssets.filter((asset) => asset.type === "video");
|
|
865
|
+
const assets = await normalizeItemImageAssets(item, mediaAssets, bodyProjection.imageSrcs);
|
|
866
|
+
const image = assets[0] ?? undefined;
|
|
867
|
+
const normalizedVideos = (
|
|
868
|
+
await Promise.all(videoAssets.map((asset) => normalizeVideoAsset(item, asset, image)))
|
|
869
|
+
).filter((asset): asset is ArticleVideoAsset => asset !== null);
|
|
870
|
+
const video = normalizedVideos.find((asset) => asset.roles?.includes("lead")) ?? normalizedVideos[0];
|
|
871
|
+
const publicationItem: NonArticlePublicationItem = {
|
|
872
|
+
type,
|
|
873
|
+
slug: item.slug,
|
|
874
|
+
section: item.section ?? "News",
|
|
875
|
+
title: item.headline ?? item.title ?? item.slug,
|
|
876
|
+
deck: item.deck ?? undefined,
|
|
877
|
+
excerpt: resolvedContent.excerpt ?? undefined,
|
|
878
|
+
body: bodyProjection.body,
|
|
879
|
+
image,
|
|
880
|
+
video,
|
|
881
|
+
assets: [...assets, ...normalizedVideos],
|
|
882
|
+
};
|
|
883
|
+
return publicationItem;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function resolveItemContentFromAttachments(editorial: unknown): Promise<ResolvedItemContent> {
|
|
887
|
+
const pointers = parseContentAttachmentPointers(editorial);
|
|
888
|
+
const excerptText = await readAttachmentPointerText(pointers.excerpt);
|
|
889
|
+
return {
|
|
890
|
+
excerpt: excerptText?.trim() || readInlineExcerpt(editorial) || null,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function readAttachmentPointerText(pointer: ContentAttachmentPointer | null): Promise<string | null> {
|
|
895
|
+
const storagePath = typeof pointer?.storagePath === "string" ? pointer.storagePath.trim() : "";
|
|
896
|
+
if (!storagePath) return null;
|
|
897
|
+
try {
|
|
898
|
+
const signedUrl = await signStorageUrl(storagePath);
|
|
899
|
+
const response = await fetch(signedUrl);
|
|
900
|
+
if (!response.ok) return null;
|
|
901
|
+
const text = await response.text();
|
|
902
|
+
return text.trim() ? text : null;
|
|
903
|
+
} catch {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function parseContentAttachmentPointers(editorial: unknown): { excerpt: ContentAttachmentPointer | null } {
|
|
909
|
+
const parsed = parseObjectMetadata(editorial);
|
|
910
|
+
const attachments = readContentAttachmentObject(parsed);
|
|
911
|
+
return {
|
|
912
|
+
excerpt: parseAttachmentPointer(attachments?.excerpt),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function readContentAttachmentObject(
|
|
917
|
+
editorial: Record<string, unknown> | undefined,
|
|
918
|
+
): Record<string, unknown> | null {
|
|
919
|
+
if (!editorial) return null;
|
|
920
|
+
if (editorial.contentAttachments && typeof editorial.contentAttachments === "object" && !Array.isArray(editorial.contentAttachments)) {
|
|
921
|
+
return editorial.contentAttachments as Record<string, unknown>;
|
|
922
|
+
}
|
|
923
|
+
const newsroom = editorial.newsroom;
|
|
924
|
+
if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
|
|
925
|
+
const nested = (newsroom as Record<string, unknown>).contentAttachments;
|
|
926
|
+
if (!nested || typeof nested !== "object" || Array.isArray(nested)) return null;
|
|
927
|
+
return nested as Record<string, unknown>;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null {
|
|
931
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
932
|
+
const record = value as Record<string, unknown>;
|
|
933
|
+
const storagePath = typeof record.storagePath === "string" ? record.storagePath.trim() : "";
|
|
934
|
+
if (!storagePath) return null;
|
|
935
|
+
return {
|
|
936
|
+
storagePath,
|
|
937
|
+
mediaType: typeof record.mediaType === "string" ? record.mediaType : null,
|
|
938
|
+
role: typeof record.role === "string" ? record.role : null,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function readInlineExcerpt(editorial: unknown): string | null {
|
|
943
|
+
const parsed = parseObjectMetadata(editorial);
|
|
944
|
+
if (!parsed) return null;
|
|
945
|
+
const direct = parsed.excerpt;
|
|
946
|
+
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
|
947
|
+
const newsroom = parsed.newsroom;
|
|
948
|
+
if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
|
|
949
|
+
const nestedExcerpt = (newsroom as Record<string, unknown>).excerpt;
|
|
950
|
+
return typeof nestedExcerpt === "string" && nestedExcerpt.trim() ? nestedExcerpt.trim() : null;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function normalizePublicationItemType(value: string): Exclude<PublicationItemType, "article"> | null {
|
|
954
|
+
if (value === "brief" || value === "correction" || value === "promo" || value === "ad" || value === "sectionHeader") return value;
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function normalizeImageAsset(item: GraphQLItem, asset: GraphQLMediaAsset): Promise<ArticleImageAsset | null> {
|
|
959
|
+
const metadata = parseObjectMetadata(asset.metadata);
|
|
960
|
+
const pictogramSlug = typeof metadata?.pictogramSlug === "string" ? metadata.pictogramSlug.trim() : "";
|
|
961
|
+
const src = await getMediaUrl(asset);
|
|
962
|
+
if (!src && !pictogramSlug) return null;
|
|
963
|
+
const themeVariants = await parseThemeVariantsMetadata(metadata?.themeVariants);
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
id: asset.id,
|
|
967
|
+
type: "image",
|
|
968
|
+
src: src ?? "",
|
|
969
|
+
alt: asset.alt ?? `Image for ${item.headline ?? item.slug}`,
|
|
970
|
+
caption: asset.caption ?? undefined,
|
|
971
|
+
credit: asset.credit ?? asset.caption ?? "Media asset",
|
|
972
|
+
roles: parseImageRoles(asset.role),
|
|
973
|
+
layout: getImageLayout(asset, metadata),
|
|
974
|
+
themeVariants,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function normalizeVideoAsset(
|
|
979
|
+
item: GraphQLItem,
|
|
980
|
+
asset: GraphQLMediaAsset,
|
|
981
|
+
fallbackPoster?: ArticleImage,
|
|
982
|
+
): Promise<ArticleVideoAsset | null> {
|
|
983
|
+
const src = await getMediaUrl(asset);
|
|
984
|
+
if (!src) return null;
|
|
985
|
+
const metadata = parseObjectMetadata(asset.metadata);
|
|
986
|
+
const posterFromMetadata = typeof metadata?.posterSrc === "string" ? metadata.posterSrc.trim() : "";
|
|
987
|
+
const posterSrc = posterFromMetadata || fallbackPoster?.src || undefined;
|
|
988
|
+
const durationSeconds =
|
|
989
|
+
typeof metadata?.durationSeconds === "number" && Number.isFinite(metadata.durationSeconds)
|
|
990
|
+
? metadata.durationSeconds
|
|
991
|
+
: undefined;
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
id: asset.id,
|
|
995
|
+
type: "video",
|
|
996
|
+
src,
|
|
997
|
+
posterSrc,
|
|
998
|
+
alt: asset.alt ?? `Video for ${item.headline ?? item.slug}`,
|
|
999
|
+
caption: asset.caption ?? undefined,
|
|
1000
|
+
credit: asset.credit ?? asset.caption ?? "Media asset",
|
|
1001
|
+
durationSeconds,
|
|
1002
|
+
roles: parseVideoRoles(asset.role),
|
|
1003
|
+
themeVariants: await parseVideoThemeVariantsMetadata(metadata?.themeVariants),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function parseVideoRoles(role: string | null | undefined): ArticleVideoAsset["roles"] {
|
|
1008
|
+
const parsed = parseImageRoles(role);
|
|
1009
|
+
if (!parsed?.length) return ["lead"];
|
|
1010
|
+
return parsed.filter((entry): entry is NonNullable<ArticleVideoAsset["roles"]>[number] =>
|
|
1011
|
+
entry === "lead" || entry === "feature" || entry === "thumbnail",
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function getMediaUrl(asset: GraphQLMediaAsset): Promise<string | null> {
|
|
1016
|
+
if (asset.storagePath) return resolveReaderStorageUrl(asset.storagePath);
|
|
1017
|
+
return asset.externalUrl ?? null;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function getImageLayout(asset: GraphQLMediaAsset, metadata?: Record<string, unknown>): ArticleImageLayout | undefined {
|
|
1021
|
+
const aspectRatio = asset.aspectRatio ?? (asset.width && asset.height ? asset.width / asset.height : null);
|
|
1022
|
+
if (!aspectRatio) return undefined;
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
minHeight: asset.minHeight ?? 110,
|
|
1026
|
+
preferredHeight: asset.preferredHeight ?? Math.round(220 / aspectRatio),
|
|
1027
|
+
maxHeight: asset.maxHeight ?? 440,
|
|
1028
|
+
aspectRatio,
|
|
1029
|
+
crop: asset.crop === "contain" ? "contain" : "cover",
|
|
1030
|
+
wrapsText: asset.wrapsText ?? true,
|
|
1031
|
+
inlineFloat: parseInlineFloatMetadata(metadata?.inlineFloat),
|
|
1032
|
+
focalPoint:
|
|
1033
|
+
typeof asset.focalX === "number" && typeof asset.focalY === "number"
|
|
1034
|
+
? {
|
|
1035
|
+
x: asset.focalX,
|
|
1036
|
+
y: asset.focalY,
|
|
1037
|
+
}
|
|
1038
|
+
: undefined,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function parseThemeVariantsMetadata(value: unknown): Promise<ArticleImageThemeVariants | undefined> {
|
|
1043
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1044
|
+
const variants = value as Record<string, unknown>;
|
|
1045
|
+
const dark = await resolveThemeVariantSource(variants.dark);
|
|
1046
|
+
if (!dark) return undefined;
|
|
1047
|
+
return { dark: { src: dark } };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function parseVideoThemeVariantsMetadata(value: unknown): Promise<ArticleVideoThemeVariants | undefined> {
|
|
1051
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1052
|
+
const variants = value as Record<string, unknown>;
|
|
1053
|
+
const [light, dark] = await Promise.all([
|
|
1054
|
+
resolveThemeVariantSource(variants.light),
|
|
1055
|
+
resolveThemeVariantSource(variants.dark),
|
|
1056
|
+
]);
|
|
1057
|
+
if (!light && !dark) return undefined;
|
|
1058
|
+
return {
|
|
1059
|
+
...(light ? { light: { src: light } } : {}),
|
|
1060
|
+
...(dark ? { dark: { src: dark } } : {}),
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function resolveThemeVariantSource(value: unknown): Promise<string | undefined> {
|
|
1065
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1066
|
+
const entry = value as Record<string, unknown>;
|
|
1067
|
+
const storagePath = typeof entry.storagePath === "string" && entry.storagePath.trim() ? entry.storagePath.trim() : null;
|
|
1068
|
+
if (storagePath) return resolveReaderStorageUrl(storagePath);
|
|
1069
|
+
const sourceUrl = typeof entry.sourceUrl === "string" && entry.sourceUrl.trim() ? entry.sourceUrl.trim() : null;
|
|
1070
|
+
if (sourceUrl) return sourceUrl;
|
|
1071
|
+
const src = typeof entry.src === "string" && entry.src.trim() ? entry.src.trim() : null;
|
|
1072
|
+
return src ?? undefined;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function parseObjectMetadata(value: unknown): Record<string, unknown> | undefined {
|
|
1076
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
1077
|
+
if (typeof value !== "string") return undefined;
|
|
1078
|
+
try {
|
|
1079
|
+
const parsed = JSON.parse(value) as unknown;
|
|
1080
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
1081
|
+
? parsed as Record<string, unknown>
|
|
1082
|
+
: undefined;
|
|
1083
|
+
} catch {
|
|
1084
|
+
return undefined;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function parseInlineFloatMetadata(value: unknown): ArticleImageLayout["inlineFloat"] {
|
|
1089
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1090
|
+
const record = value as Record<string, unknown>;
|
|
1091
|
+
return {
|
|
1092
|
+
minColumnCount: optionalPositiveNumber(record.minColumnCount),
|
|
1093
|
+
columnSpan: optionalPositiveNumber(record.columnSpan),
|
|
1094
|
+
widthRatio: optionalPositiveNumber(record.widthRatio),
|
|
1095
|
+
narrowWidthRatio: optionalPositiveNumber(record.narrowWidthRatio),
|
|
1096
|
+
maxWidthRatio: optionalPositiveNumber(record.maxWidthRatio),
|
|
1097
|
+
minWidth: optionalPositiveNumber(record.minWidth),
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function optionalPositiveNumber(value: unknown): number | undefined {
|
|
1102
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function getFallbackImage(item: GraphQLItem): ArticleImage {
|
|
1106
|
+
return {
|
|
1107
|
+
src: "https://images.unsplash.com/photo-1495020689067-958852a7765e?auto=format&fit=crop&w=1200&q=80",
|
|
1108
|
+
alt: `Editorial image for ${item.headline ?? item.slug}`,
|
|
1109
|
+
caption: "Fallback editorial image.",
|
|
1110
|
+
credit: "Fallback image",
|
|
1111
|
+
layout: {
|
|
1112
|
+
minHeight: 120,
|
|
1113
|
+
preferredHeight: 220,
|
|
1114
|
+
maxHeight: 420,
|
|
1115
|
+
aspectRatio: 1.5,
|
|
1116
|
+
crop: "cover",
|
|
1117
|
+
wrapsText: true,
|
|
1118
|
+
},
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function parseImageRoles(role: string | null | undefined): ArticleImageAsset["roles"] {
|
|
1123
|
+
const roles = (role ?? "")
|
|
1124
|
+
.split(/[,\s]+/)
|
|
1125
|
+
.map((candidate) => candidate.trim())
|
|
1126
|
+
.filter((candidate): candidate is NonNullable<ArticleImageAsset["roles"]>[number] =>
|
|
1127
|
+
IMAGE_ROLES.includes(candidate as NonNullable<ArticleImageAsset["roles"]>[number]),
|
|
1128
|
+
);
|
|
1129
|
+
return roles.length > 0 ? roles : ["lead", "continuation", "continuationInset"];
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function normalizeShortSlug(value: string | null | undefined): string | undefined {
|
|
1133
|
+
const shortSlug = value?.trim().toUpperCase();
|
|
1134
|
+
return shortSlug || undefined;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function assertNoGraphQLErrors(errors: unknown[] | null | undefined): void {
|
|
1138
|
+
if (!errors?.length) return;
|
|
1139
|
+
throw new Error(`GraphQL content request failed: ${JSON.stringify(errors)}`);
|
|
1140
|
+
}
|