@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.4
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/amplify/data/resource.js +26 -0
- package/amplify/data/resource.ts +30 -0
- package/amplify/data/schema.js +26 -0
- package/amplify/data/schema.ts +30 -0
- package/amplify/functions/content-actions/handler.py +107 -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/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/site-backend.js +10 -0
- package/amplify/site-backend.ts +15 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -2
- package/app/globals.css +1 -4
- package/app/layout.tsx +8 -12
- package/app/robots.ts +11 -0
- package/bin/papyrus-infra.mjs +46 -16
- package/components/news-desk-page.tsx +1 -4
- package/components/staging-banner.tsx +11 -0
- package/infra/amplify-app-shell.js +123 -80
- package/infra/amplify-app-shell.ts +139 -113
- package/infra/build-specs.js +86 -0
- package/infra/build-specs.ts +132 -0
- package/infra/index.js +2 -0
- package/infra/site-config.js +125 -0
- package/infra/site-config.ts +163 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +16 -0
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +182 -37
- package/lib/layout-scenarios.ts +8 -8
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +44 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/middleware.ts +46 -34
- package/package.json +6 -1
- package/renderers/pretext/article-page.tsx +1 -6
- package/renderers/pretext/presentation-shell.tsx +2 -9
- package/routes.manifest.json +1 -1
- package/src/with-papyrus.mjs +15 -2
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export type AmplifyAppShellSiteConfig = {
|
|
2
|
+
siteId: string;
|
|
3
|
+
repository: string;
|
|
4
|
+
brand: string;
|
|
5
|
+
frontend: "pretext" | "markus-static";
|
|
6
|
+
hostedZoneId: string;
|
|
7
|
+
cms: {
|
|
8
|
+
appName?: string;
|
|
9
|
+
domainName: string;
|
|
10
|
+
buildComputeType?: "STANDARD" | "STANDARD_8GB";
|
|
11
|
+
environment: Record<string, string>;
|
|
12
|
+
stagingDomainName?: string;
|
|
13
|
+
};
|
|
14
|
+
reader?: {
|
|
15
|
+
appName?: string;
|
|
16
|
+
domainName: string;
|
|
17
|
+
branchName?: string;
|
|
18
|
+
buildCommand: string;
|
|
19
|
+
baseDirectory: string;
|
|
20
|
+
environment?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
papyrusVersion: string;
|
|
23
|
+
storagePreviewPrefix?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
27
|
+
const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
28
|
+
const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
|
|
29
|
+
const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
30
|
+
const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
|
|
31
|
+
const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
|
|
32
|
+
const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
|
|
33
|
+
const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
|
|
34
|
+
|
|
35
|
+
function fail(field: string, problem: string): never {
|
|
36
|
+
throw new Error(`site.json: ${field} ${problem}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireObject(value: unknown, field: string): Record<string, unknown> {
|
|
40
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
41
|
+
fail(field, "must be an object");
|
|
42
|
+
}
|
|
43
|
+
return value as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requireString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string {
|
|
47
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
48
|
+
fail(field, "must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
if (pattern && !pattern.test(value as string)) {
|
|
51
|
+
fail(field, `is invalid${patternHint ? ` (${patternHint})` : ""}: ${JSON.stringify(value)}`);
|
|
52
|
+
}
|
|
53
|
+
return value as string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function optionalString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string | undefined {
|
|
57
|
+
return value === undefined ? undefined : requireString(value, field, pattern, patternHint);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requireStringRecord(value: unknown, field: string): Record<string, string> {
|
|
61
|
+
const record = requireObject(value, field);
|
|
62
|
+
const result: Record<string, string> = {};
|
|
63
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
64
|
+
if (typeof entry !== "string") fail(`${field}.${key}`, "must be a string");
|
|
65
|
+
result[key] = entry as string;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], field: string): void {
|
|
71
|
+
for (const key of Object.keys(record)) {
|
|
72
|
+
if (!allowed.includes(key)) fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
|
|
77
|
+
if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
|
|
78
|
+
const [, ...rest] = config.cms.domainName.split(".");
|
|
79
|
+
return ["staging", ...rest].join(".");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
|
|
83
|
+
return config.storagePreviewPrefix ?? "preview/";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
|
|
87
|
+
const record = requireObject(raw, "site");
|
|
88
|
+
rejectUnknownKeys(
|
|
89
|
+
record,
|
|
90
|
+
["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"],
|
|
91
|
+
"site",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
|
|
95
|
+
const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
|
|
96
|
+
const brand = requireString(record.brand, "brand");
|
|
97
|
+
const frontend = requireString(record.frontend, "frontend");
|
|
98
|
+
if (frontend !== "pretext" && frontend !== "markus-static") {
|
|
99
|
+
fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
|
|
100
|
+
}
|
|
101
|
+
const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
|
|
102
|
+
const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
|
|
103
|
+
const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
|
|
104
|
+
|
|
105
|
+
const cmsRecord = requireObject(record.cms, "cms");
|
|
106
|
+
rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
|
|
107
|
+
const buildComputeType = cmsRecord.buildComputeType;
|
|
108
|
+
if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
|
|
109
|
+
fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
|
|
110
|
+
}
|
|
111
|
+
const cms: AmplifyAppShellSiteConfig["cms"] = {
|
|
112
|
+
appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
|
|
113
|
+
domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
|
|
114
|
+
buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
|
|
115
|
+
environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
|
|
116
|
+
stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let reader: AmplifyAppShellSiteConfig["reader"];
|
|
120
|
+
if (record.reader !== undefined) {
|
|
121
|
+
if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
|
|
122
|
+
const readerRecord = requireObject(record.reader, "reader");
|
|
123
|
+
rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
|
|
124
|
+
reader = {
|
|
125
|
+
appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
|
|
126
|
+
domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
|
|
127
|
+
branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
|
|
128
|
+
buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
|
|
129
|
+
baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
|
|
130
|
+
environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
|
|
131
|
+
};
|
|
132
|
+
} else if (frontend === "markus-static") {
|
|
133
|
+
fail("reader", `is required when frontend is "markus-static"`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const config: AmplifyAppShellSiteConfig = {
|
|
137
|
+
siteId,
|
|
138
|
+
repository,
|
|
139
|
+
brand,
|
|
140
|
+
frontend,
|
|
141
|
+
hostedZoneId,
|
|
142
|
+
cms,
|
|
143
|
+
reader,
|
|
144
|
+
papyrusVersion,
|
|
145
|
+
storagePreviewPrefix,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
|
|
149
|
+
if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
|
|
150
|
+
const redirectUrls = redirectValue.split(",").map((url) => url.trim());
|
|
151
|
+
const requiredOrigins = [
|
|
152
|
+
`https://${cms.domainName}/`,
|
|
153
|
+
`https://${resolveStagingDomainName(config)}/`,
|
|
154
|
+
LOCAL_DEVELOPMENT_ORIGIN,
|
|
155
|
+
];
|
|
156
|
+
for (const origin of requiredOrigins) {
|
|
157
|
+
if (!redirectUrls.includes(origin)) {
|
|
158
|
+
fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return config;
|
|
163
|
+
}
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from "./content-types";
|
|
11
11
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
12
12
|
import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
|
|
13
|
+
import { getContentSource } from "./site-env";
|
|
13
14
|
|
|
14
15
|
const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
|
|
15
16
|
|
|
@@ -34,7 +35,7 @@ function withReaderCache<T>(
|
|
|
34
35
|
tags: string[],
|
|
35
36
|
loader: () => Promise<T>,
|
|
36
37
|
): Promise<T> {
|
|
37
|
-
if (bypassReaderCacheInDevelopment) return loader();
|
|
38
|
+
if (bypassReaderCacheInDevelopment || getContentSource() === "drafts") return loader();
|
|
38
39
|
return unstable_cache(loader, cacheKey, {
|
|
39
40
|
tags,
|
|
40
41
|
revalidate: READER_REVALIDATE_SECONDS,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { generateClient } from "aws-amplify/data";
|
|
2
|
+
import type { Schema } from "../amplify/data/resource";
|
|
3
|
+
|
|
4
|
+
export type ContentActionError = { code: string; message: string; line: number | null };
|
|
5
|
+
|
|
6
|
+
export type DeriveMarkusInput = { frontMatterYaml: string | null; bodyMarkus: string; includeIr?: boolean };
|
|
7
|
+
export type SaveItemDraftInput = {
|
|
8
|
+
id: string | null;
|
|
9
|
+
type: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
section: string | null;
|
|
12
|
+
frontMatterYaml: string | null;
|
|
13
|
+
bodyMarkus: string;
|
|
14
|
+
aliases: string[];
|
|
15
|
+
expectedContentHash: string | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };
|
|
19
|
+
export type SavedItemSummary = {
|
|
20
|
+
id: string;
|
|
21
|
+
contentHash: string;
|
|
22
|
+
status: string;
|
|
23
|
+
slug: string;
|
|
24
|
+
versionNumber: number | null;
|
|
25
|
+
};
|
|
26
|
+
export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
|
|
27
|
+
export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
|
|
28
|
+
export type UnpublishItemResult = { ok: true; changed: boolean };
|
|
29
|
+
|
|
30
|
+
export class ContentActionFailure extends Error {
|
|
31
|
+
readonly errors: ContentActionError[];
|
|
32
|
+
|
|
33
|
+
constructor(errors: ContentActionError[]) {
|
|
34
|
+
super(errors.map((error) => error.message).join("; ") || "Content action failed");
|
|
35
|
+
this.name = "ContentActionFailure";
|
|
36
|
+
this.errors = errors;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type GraphqlEnvelope = { data?: unknown; errors?: { message?: string }[] | null };
|
|
41
|
+
|
|
42
|
+
export type ContentActionsTransport = {
|
|
43
|
+
queries: { deriveMarkus: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope> };
|
|
44
|
+
mutations: {
|
|
45
|
+
saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
46
|
+
publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
47
|
+
unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const USER_POOL = { authMode: "userPool" } as const;
|
|
52
|
+
|
|
53
|
+
function parseActionResponse<T>(envelope: GraphqlEnvelope): T {
|
|
54
|
+
if (envelope.errors && envelope.errors.length > 0) {
|
|
55
|
+
throw new Error(envelope.errors.map((error) => error.message ?? "GraphQL error").join("; "));
|
|
56
|
+
}
|
|
57
|
+
const raw = envelope.data;
|
|
58
|
+
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
59
|
+
if (!parsed || typeof parsed !== "object") {
|
|
60
|
+
throw new Error("The content action returned no data.");
|
|
61
|
+
}
|
|
62
|
+
const response = parsed as { ok?: boolean; errors?: ContentActionError[] };
|
|
63
|
+
if (response.ok !== true) {
|
|
64
|
+
throw new ContentActionFailure(response.errors ?? []);
|
|
65
|
+
}
|
|
66
|
+
return parsed as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createContentActionsClient(transport: ContentActionsTransport) {
|
|
70
|
+
return {
|
|
71
|
+
async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
|
|
72
|
+
return parseActionResponse(await transport.queries.deriveMarkus({ input: JSON.stringify(input) }, USER_POOL));
|
|
73
|
+
},
|
|
74
|
+
async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
|
|
75
|
+
return parseActionResponse(await transport.mutations.saveItemDraft({ input: JSON.stringify(input) }, USER_POOL));
|
|
76
|
+
},
|
|
77
|
+
async publishItem(id: string): Promise<PublishItemResult> {
|
|
78
|
+
return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
79
|
+
},
|
|
80
|
+
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
81
|
+
return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let defaultClient: ReturnType<typeof createContentActionsClient> | null = null;
|
|
87
|
+
|
|
88
|
+
function contentActionsClient() {
|
|
89
|
+
if (!defaultClient) {
|
|
90
|
+
defaultClient = createContentActionsClient(generateClient<Schema>() as unknown as ContentActionsTransport);
|
|
91
|
+
}
|
|
92
|
+
return defaultClient;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient().deriveMarkus(input);
|
|
96
|
+
export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
|
|
97
|
+
export const publishItem = (id: string) => contentActionsClient().publishItem(id);
|
|
98
|
+
export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ContentRepository } from "./content-types";
|
|
2
2
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
3
3
|
import { getLayoutScenario } from "./layout-scenarios";
|
|
4
|
+
import { assertContentSourceMatchesEnv } from "./site-env";
|
|
5
|
+
|
|
6
|
+
assertContentSourceMatchesEnv();
|
|
4
7
|
|
|
5
8
|
export const contentRepository: ContentRepository = {
|
|
6
9
|
loadEditionContent({ scenarioId, editionDate, editionSlug } = {}) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { getContentSource, type ContentSource } from "./site-env";
|
|
3
|
+
|
|
4
|
+
export type ContentSourceContext = {
|
|
5
|
+
source: ContentSource;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
|
|
9
|
+
|
|
10
|
+
export function runWithContentSource<T>(source: ContentSource, operation: () => Promise<T>): Promise<T> {
|
|
11
|
+
return contentSourceStorage.run({ source }, operation);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function currentContentSource(): ContentSource {
|
|
15
|
+
return contentSourceStorage.getStore()?.source ?? getContentSource();
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* Default for `papyrus-site-theme` when a publication ships no publication/theme.css. */
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
} from "./publication-items";
|
|
18
18
|
import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
|
|
19
19
|
import { SITE_BRAND } from "./site-brand";
|
|
20
|
+
import { currentContentSource, runWithContentSource } from "./content-source-context";
|
|
21
|
+
import { getContentSource } from "./site-env";
|
|
20
22
|
|
|
21
|
-
const AUTH_MODE = "identityPool";
|
|
22
23
|
const DEFAULT_EDITION_SLUG = "current";
|
|
23
24
|
const PUBLISHED_STATUS = "published";
|
|
24
|
-
const
|
|
25
|
+
const DRAFT_STATUS = "draft";
|
|
26
|
+
const ARTICLE_TYPE = "article";
|
|
25
27
|
|
|
26
28
|
type DataClient = ReturnType<typeof generateClient<Schema>>;
|
|
27
29
|
type EditionPublishedAtIndexQuery = (
|
|
@@ -64,8 +66,6 @@ type GraphQLEditionItem = {
|
|
|
64
66
|
|
|
65
67
|
type GraphQLItem = {
|
|
66
68
|
id: string;
|
|
67
|
-
sourceItemId?: string | null;
|
|
68
|
-
itemLineageId?: string | null;
|
|
69
69
|
versionNumber?: number | null;
|
|
70
70
|
type: string;
|
|
71
71
|
status: string;
|
|
@@ -133,19 +133,32 @@ const IMAGE_ROLES: NonNullable<ArticleImageAsset["roles"]> = [
|
|
|
133
133
|
let readerContextClient: DataClient | null = null;
|
|
134
134
|
let buildTimeClient: DataClient | null = null;
|
|
135
135
|
|
|
136
|
+
function authMode(): "userPool" | "identityPool" {
|
|
137
|
+
return currentContentSource() === "drafts" ? "userPool" : "identityPool";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isVisibleItemStatus(status: string): boolean {
|
|
141
|
+
if (status === PUBLISHED_STATUS) return true;
|
|
142
|
+
return currentContentSource() === "drafts" && status === DRAFT_STATUS;
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
137
146
|
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
const source = getContentSource();
|
|
148
|
+
const nextServerContext = source === "drafts" ? { cookies: (await import("next/headers")).cookies } : null;
|
|
149
|
+
return runWithContentSource(source, () =>
|
|
150
|
+
runWithAmplifyServerContext({
|
|
151
|
+
nextServerContext,
|
|
152
|
+
operation: async () => {
|
|
153
|
+
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
154
|
+
try {
|
|
155
|
+
return await operation();
|
|
156
|
+
} finally {
|
|
157
|
+
readerContextClient = null;
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
export const graphqlContentRepository: ContentRepository = {
|
|
@@ -200,7 +213,7 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
200
213
|
getArticle(slug) {
|
|
201
214
|
return withReaderGraphQLContext(async () => {
|
|
202
215
|
const item = await getItemBySlug(slug);
|
|
203
|
-
if (!item || item.type !==
|
|
216
|
+
if (!item || item.type !== ARTICLE_TYPE || !isVisibleItemStatus(item.status)) return undefined;
|
|
204
217
|
return normalizeArticle(item, await listMediaAssets(item.id));
|
|
205
218
|
});
|
|
206
219
|
},
|
|
@@ -218,9 +231,9 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
218
231
|
|
|
219
232
|
listArticleSlugs() {
|
|
220
233
|
return withReaderGraphQLContext(async () => {
|
|
221
|
-
const items = await listItemsByTypeStatus(
|
|
234
|
+
const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
|
|
222
235
|
return items
|
|
223
|
-
.filter((item) => item.type ===
|
|
236
|
+
.filter((item) => item.type === ARTICLE_TYPE && isVisibleItemStatus(item.status))
|
|
224
237
|
.map((item) => item.slug)
|
|
225
238
|
.sort();
|
|
226
239
|
});
|
|
@@ -232,7 +245,7 @@ function getClient(): DataClient {
|
|
|
232
245
|
|
|
233
246
|
if (!buildTimeClient) {
|
|
234
247
|
getAmplifyServerRuntime();
|
|
235
|
-
buildTimeClient = generateClient<Schema>({ authMode:
|
|
248
|
+
buildTimeClient = generateClient<Schema>({ authMode: authMode() });
|
|
236
249
|
}
|
|
237
250
|
return buildTimeClient;
|
|
238
251
|
}
|
|
@@ -331,7 +344,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
331
344
|
await Promise.all(
|
|
332
345
|
editionItems.map(async (editionItem) => {
|
|
333
346
|
const item = await getItemById(editionItem.publishedItemId);
|
|
334
|
-
if (!item || item.status
|
|
347
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
335
348
|
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
336
349
|
}),
|
|
337
350
|
)
|
|
@@ -344,7 +357,7 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
344
357
|
await Promise.all(
|
|
345
358
|
missingLayoutItems.map(async (itemId) => {
|
|
346
359
|
const item = await getItemBySlug(itemId);
|
|
347
|
-
if (!item || item.status
|
|
360
|
+
if (!item || !isVisibleItemStatus(item.status)) return null;
|
|
348
361
|
return normalizePublicationItem(item, await listMediaAssets(item.id));
|
|
349
362
|
}),
|
|
350
363
|
)
|
|
@@ -461,7 +474,7 @@ async function loadEditionItem(editionDate: string, itemSlug: string): Promise<P
|
|
|
461
474
|
const editionItems = await listEditionItems(edition.id);
|
|
462
475
|
for (const editionItem of editionItems) {
|
|
463
476
|
const item = await getItemById(editionItem.publishedItemId);
|
|
464
|
-
if (!item || item.slug !== itemSlug || item.status
|
|
477
|
+
if (!item || item.slug !== itemSlug || !isVisibleItemStatus(item.status)) continue;
|
|
465
478
|
return (await normalizePublicationItem(item, await listMediaAssets(item.id))) ?? undefined;
|
|
466
479
|
}
|
|
467
480
|
return undefined;
|
|
@@ -505,36 +518,168 @@ function isMissingReaderBackendError(error: unknown): boolean {
|
|
|
505
518
|
}
|
|
506
519
|
|
|
507
520
|
function getEditionPublishedAtIndexQuery(): EditionPublishedAtIndexQuery | null {
|
|
508
|
-
const editionModel = getPublishedEditionModel()
|
|
509
|
-
listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
|
|
510
|
-
};
|
|
521
|
+
const editionModel = getPublishedEditionModel();
|
|
511
522
|
return typeof editionModel.listPublishedEditionsByStatusAndPublishedAt === "function"
|
|
512
523
|
? editionModel.listPublishedEditionsByStatusAndPublishedAt
|
|
513
524
|
: null;
|
|
514
525
|
}
|
|
515
526
|
|
|
516
|
-
|
|
527
|
+
type ListOptions = Record<string, unknown>;
|
|
528
|
+
type ListResponse<T> = Promise<GraphQLListResponse<T>>;
|
|
529
|
+
|
|
530
|
+
type EditionSourceModel = {
|
|
531
|
+
publishedEditionBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLEdition>;
|
|
532
|
+
listPublishedEditionsByStatusAndEditionDate: (
|
|
533
|
+
input: { status: string; editionDate?: { eq: string } },
|
|
534
|
+
options: ListOptions,
|
|
535
|
+
) => ListResponse<GraphQLEdition>;
|
|
536
|
+
listPublishedEditionsByStatusAndPublishedAt?: EditionPublishedAtIndexQuery;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
type EditionItemSourceModel = {
|
|
540
|
+
listPublishedEditionItemsByEditionAndSortKey: (
|
|
541
|
+
input: { publishedEditionId: string },
|
|
542
|
+
options: ListOptions,
|
|
543
|
+
) => ListResponse<GraphQLEditionItem>;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
type ItemSourceModel = {
|
|
547
|
+
get: (input: { id: string }, options: ListOptions) => Promise<GraphQLGetResponse<GraphQLItem>>;
|
|
548
|
+
publishedItemBySlug: (input: { slug: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
549
|
+
listPublishedItemsByTypeStatusAndPublishedAt: (input: { typeStatus: string }, options: ListOptions) => ListResponse<GraphQLItem>;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
type MediaAssetSourceModel = {
|
|
553
|
+
listPublishedMediaAssetsByItemAndSortKey: (
|
|
554
|
+
input: { publishedItemId: string },
|
|
555
|
+
options: ListOptions,
|
|
556
|
+
) => ListResponse<GraphQLMediaAsset>;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const DRAFTS_EDITION_STATUSES = [PUBLISHED_STATUS, DRAFT_STATUS];
|
|
560
|
+
|
|
561
|
+
async function listMergedAcrossStatuses<T>(
|
|
562
|
+
statuses: string[],
|
|
563
|
+
runForStatus: (status: string, pageOptions: ListOptions) => ListResponse<T>,
|
|
564
|
+
sortValue: (row: T) => string,
|
|
565
|
+
options: ListOptions,
|
|
566
|
+
): ListResponse<T> {
|
|
567
|
+
const rows: T[] = [];
|
|
568
|
+
for (const status of statuses) {
|
|
569
|
+
let pageToken: string | null | undefined;
|
|
570
|
+
do {
|
|
571
|
+
const response = await runForStatus(status, { authMode: options.authMode, limit: 100, nextToken: pageToken });
|
|
572
|
+
if (response.errors?.length) return { data: [], errors: response.errors };
|
|
573
|
+
rows.push(...((response.data ?? []).filter(Boolean) as T[]));
|
|
574
|
+
pageToken = response.nextToken;
|
|
575
|
+
} while (pageToken);
|
|
576
|
+
}
|
|
577
|
+
const direction = options.sortDirection === "DESC" ? -1 : 1;
|
|
578
|
+
rows.sort((left, right) => direction * sortValue(left).localeCompare(sortValue(right)));
|
|
579
|
+
const offset = Number(options.nextToken ?? 0) || 0;
|
|
580
|
+
const limit = typeof options.limit === "number" ? options.limit : undefined;
|
|
581
|
+
const end = limit === undefined ? rows.length : offset + limit;
|
|
582
|
+
return {
|
|
583
|
+
data: rows.slice(offset, end),
|
|
584
|
+
nextToken: end < rows.length ? String(end) : undefined,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function getPublishedEditionModel(): EditionSourceModel {
|
|
589
|
+
if (currentContentSource() === "drafts") {
|
|
590
|
+
const model = getClient().models.Edition;
|
|
591
|
+
if (!model) throw missingProjectionModelError("Edition");
|
|
592
|
+
return {
|
|
593
|
+
publishedEditionBySlug: (input, options) => model.editionBySlug(input, options) as ListResponse<GraphQLEdition>,
|
|
594
|
+
listPublishedEditionsByStatusAndEditionDate: (input, options) =>
|
|
595
|
+
listMergedAcrossStatuses<GraphQLEdition>(
|
|
596
|
+
DRAFTS_EDITION_STATUSES,
|
|
597
|
+
(status, pageOptions) =>
|
|
598
|
+
model.listEditionsByStatusAndEditionDate({ ...input, status }, pageOptions) as ListResponse<GraphQLEdition>,
|
|
599
|
+
(edition) => edition.editionDate,
|
|
600
|
+
options,
|
|
601
|
+
),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
517
604
|
const model = getClient().models.PublishedEdition;
|
|
518
605
|
if (!model) throw missingProjectionModelError("PublishedEdition");
|
|
519
|
-
return model;
|
|
606
|
+
return model as unknown as EditionSourceModel;
|
|
520
607
|
}
|
|
521
608
|
|
|
522
|
-
function getPublishedEditionItemModel() {
|
|
609
|
+
function getPublishedEditionItemModel(): EditionItemSourceModel {
|
|
610
|
+
if (currentContentSource() === "drafts") {
|
|
611
|
+
const model = getClient().models.EditionItem;
|
|
612
|
+
if (!model) throw missingProjectionModelError("EditionItem");
|
|
613
|
+
return {
|
|
614
|
+
listPublishedEditionItemsByEditionAndSortKey: async ({ publishedEditionId }, options) => {
|
|
615
|
+
const response = (await model.listEditionItemsByEditionAndSortKey(
|
|
616
|
+
{ editionId: publishedEditionId },
|
|
617
|
+
options,
|
|
618
|
+
)) as unknown as GraphQLListResponse<{ id: string; editionId: string; itemId: string; sortKey: string }>;
|
|
619
|
+
return {
|
|
620
|
+
...response,
|
|
621
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({
|
|
622
|
+
id: row.id,
|
|
623
|
+
publishedEditionId: row.editionId,
|
|
624
|
+
publishedItemId: row.itemId,
|
|
625
|
+
sortKey: row.sortKey,
|
|
626
|
+
})),
|
|
627
|
+
};
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
523
631
|
const model = getClient().models.PublishedEditionItem;
|
|
524
632
|
if (!model) throw missingProjectionModelError("PublishedEditionItem");
|
|
525
|
-
return model;
|
|
633
|
+
return model as unknown as EditionItemSourceModel;
|
|
526
634
|
}
|
|
527
635
|
|
|
528
|
-
function getPublishedItemModel() {
|
|
636
|
+
function getPublishedItemModel(): ItemSourceModel {
|
|
637
|
+
if (currentContentSource() === "drafts") {
|
|
638
|
+
const model = getClient().models.Item;
|
|
639
|
+
if (!model) throw missingProjectionModelError("Item");
|
|
640
|
+
return {
|
|
641
|
+
get: (input, options) => model.get(input, options) as Promise<GraphQLGetResponse<GraphQLItem>>,
|
|
642
|
+
publishedItemBySlug: (input, options) => model.itemBySlug(input, options) as ListResponse<GraphQLItem>,
|
|
643
|
+
listPublishedItemsByTypeStatusAndPublishedAt: ({ typeStatus }, options) => {
|
|
644
|
+
const itemType = typeStatus.split("#")[0];
|
|
645
|
+
return listMergedAcrossStatuses<GraphQLItem>(
|
|
646
|
+
[DRAFT_STATUS, PUBLISHED_STATUS],
|
|
647
|
+
(status, pageOptions) =>
|
|
648
|
+
model.listItemsByTypeStatusAndPublishedAt(
|
|
649
|
+
{ typeStatus: `${itemType}#${status}` },
|
|
650
|
+
pageOptions,
|
|
651
|
+
) as ListResponse<GraphQLItem>,
|
|
652
|
+
(item) => item.publishedAt ?? "",
|
|
653
|
+
options,
|
|
654
|
+
);
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
529
658
|
const model = getClient().models.PublishedItem;
|
|
530
659
|
if (!model) throw missingProjectionModelError("PublishedItem");
|
|
531
|
-
return model;
|
|
660
|
+
return model as unknown as ItemSourceModel;
|
|
532
661
|
}
|
|
533
662
|
|
|
534
|
-
function getPublishedMediaAssetModel() {
|
|
663
|
+
function getPublishedMediaAssetModel(): MediaAssetSourceModel {
|
|
664
|
+
if (currentContentSource() === "drafts") {
|
|
665
|
+
const model = getClient().models.MediaAsset;
|
|
666
|
+
if (!model) throw missingProjectionModelError("MediaAsset");
|
|
667
|
+
return {
|
|
668
|
+
listPublishedMediaAssetsByItemAndSortKey: async ({ publishedItemId }, options) => {
|
|
669
|
+
const response = (await model.listMediaAssetsByItemAndSortKey(
|
|
670
|
+
{ itemId: publishedItemId },
|
|
671
|
+
options,
|
|
672
|
+
)) as unknown as GraphQLListResponse<Omit<GraphQLMediaAsset, "publishedItemId"> & { itemId: string }>;
|
|
673
|
+
return {
|
|
674
|
+
...response,
|
|
675
|
+
data: (response.data ?? []).filter(Boolean).map((row) => ({ ...row, publishedItemId: row.itemId })),
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
535
680
|
const model = getClient().models.PublishedMediaAsset;
|
|
536
681
|
if (!model) throw missingProjectionModelError("PublishedMediaAsset");
|
|
537
|
-
return model;
|
|
682
|
+
return model as unknown as MediaAssetSourceModel;
|
|
538
683
|
}
|
|
539
684
|
|
|
540
685
|
function missingProjectionModelError(modelName: string): Error {
|
|
@@ -561,7 +706,7 @@ async function listItemsByTypeStatus(typeStatus: string): Promise<GraphQLItem[]>
|
|
|
561
706
|
}
|
|
562
707
|
|
|
563
708
|
async function getItemById(id: string): Promise<GraphQLItem | null> {
|
|
564
|
-
const response = await getPublishedItemModel().get({ id }, { authMode:
|
|
709
|
+
const response = await getPublishedItemModel().get({ id }, { authMode: authMode() });
|
|
565
710
|
return readGetResponse<GraphQLItem>(response);
|
|
566
711
|
}
|
|
567
712
|
|
|
@@ -583,7 +728,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
|
|
|
583
728
|
|
|
584
729
|
do {
|
|
585
730
|
const response = await operation({
|
|
586
|
-
authMode:
|
|
731
|
+
authMode: authMode(),
|
|
587
732
|
limit: 100,
|
|
588
733
|
nextToken,
|
|
589
734
|
});
|
|
@@ -597,7 +742,7 @@ async function listAll<T>(operation: (options: Record<string, unknown>) => Promi
|
|
|
597
742
|
|
|
598
743
|
async function listFirst<T>(operation: (options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>): Promise<T[]> {
|
|
599
744
|
const response = await operation({
|
|
600
|
-
authMode:
|
|
745
|
+
authMode: authMode(),
|
|
601
746
|
limit: 1,
|
|
602
747
|
sortDirection: "DESC",
|
|
603
748
|
});
|
|
@@ -611,7 +756,7 @@ async function listPage<T>(
|
|
|
611
756
|
nextToken?: string | null,
|
|
612
757
|
): Promise<{ editions: T[]; nextToken?: string | null }> {
|
|
613
758
|
const response = await operation({
|
|
614
|
-
authMode:
|
|
759
|
+
authMode: authMode(),
|
|
615
760
|
limit,
|
|
616
761
|
nextToken,
|
|
617
762
|
sortDirection: "DESC",
|