@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.5
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/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/globals.css +19 -0
- package/app/layout.tsx +4 -0
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +72 -16
- package/components/article-video.tsx +112 -3
- package/components/staging-banner.tsx +11 -0
- package/infra/amplify-app-shell.js +178 -80
- package/infra/amplify-app-shell.ts +217 -113
- package/infra/build-specs.js +86 -0
- package/infra/build-specs.ts +132 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +155 -0
- package/infra/site-config.ts +200 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +16 -0
- package/lib/graphql-content-repository.ts +182 -37
- package/lib/site-brand.ts +13 -0
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +51 -1
- package/package.json +11 -1
- package/routes.manifest.json +11 -1
- 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
|
@@ -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",
|
package/lib/site-brand.ts
CHANGED
|
@@ -34,6 +34,18 @@ export type SiteBrandDemoEdition = {
|
|
|
34
34
|
editionVideo?: ArticleVideoAsset;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Video slot: how a publication supplies its own VideoML scene components.
|
|
39
|
+
* Paths are relative to the publication root.
|
|
40
|
+
*/
|
|
41
|
+
export type SiteBrandVideo = {
|
|
42
|
+
/** TSX module that registers the brand's `@videoml` scene components via `window.Babulus.registerComponent`. */
|
|
43
|
+
bundleEntry: string;
|
|
44
|
+
/** DSL element aliases for the browser preview, e.g. `{ "quote-card": "acme-quote-card" }`. */
|
|
45
|
+
sceneComponents?: Record<string, string>;
|
|
46
|
+
rhythm?: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
|
|
37
49
|
export type SiteBrand = {
|
|
38
50
|
id: SiteBrandId;
|
|
39
51
|
appTitle: string;
|
|
@@ -76,6 +88,7 @@ export type SiteBrand = {
|
|
|
76
88
|
publicationName: string;
|
|
77
89
|
components?: SiteBrandComponents;
|
|
78
90
|
demoEdition?: SiteBrandDemoEdition;
|
|
91
|
+
video?: SiteBrandVideo;
|
|
79
92
|
};
|
|
80
93
|
|
|
81
94
|
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|
package/lib/site-env.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type SiteEnv = "production" | "staging" | "development";
|
|
2
|
+
export type ContentSource = "published" | "drafts";
|
|
3
|
+
|
|
4
|
+
type EnvironmentVariables = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
const SITE_ENVIRONMENTS: readonly SiteEnv[] = ["production", "staging", "development"];
|
|
7
|
+
const CONTENT_SOURCES: readonly ContentSource[] = ["published", "drafts"];
|
|
8
|
+
const PRODUCTION_BRANCH = "main";
|
|
9
|
+
|
|
10
|
+
export function getSiteEnv(environment: EnvironmentVariables = process.env): SiteEnv {
|
|
11
|
+
const configured = (environment.SITE_ENV ?? "").trim();
|
|
12
|
+
if (!configured) return "development";
|
|
13
|
+
if (!SITE_ENVIRONMENTS.includes(configured as SiteEnv)) {
|
|
14
|
+
throw new Error(`SITE_ENV must be one of ${SITE_ENVIRONMENTS.join(", ")}; received "${configured}".`);
|
|
15
|
+
}
|
|
16
|
+
if (configured === "production") {
|
|
17
|
+
const branch = (environment.AWS_BRANCH ?? "").trim();
|
|
18
|
+
if (branch && branch !== PRODUCTION_BRANCH) return "staging";
|
|
19
|
+
}
|
|
20
|
+
return configured as SiteEnv;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getContentSource(environment: EnvironmentVariables = process.env): ContentSource {
|
|
24
|
+
const configured = (environment.PAPYRUS_CONTENT_SOURCE ?? "").trim();
|
|
25
|
+
if (!configured) return "published";
|
|
26
|
+
if (!CONTENT_SOURCES.includes(configured as ContentSource)) {
|
|
27
|
+
throw new Error(`PAPYRUS_CONTENT_SOURCE must be one of ${CONTENT_SOURCES.join(", ")}; received "${configured}".`);
|
|
28
|
+
}
|
|
29
|
+
return configured as ContentSource;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isIndexable(environment: EnvironmentVariables = process.env): boolean {
|
|
33
|
+
return getSiteEnv(environment) === "production";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showStagingBanner(environment: EnvironmentVariables = process.env): boolean {
|
|
37
|
+
return getSiteEnv(environment) === "staging";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function analyticsAllowed(environment: EnvironmentVariables = process.env): boolean {
|
|
41
|
+
return getSiteEnv(environment) === "production";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertContentSourceMatchesEnv(environment: EnvironmentVariables = process.env): void {
|
|
45
|
+
if (getContentSource(environment) !== "drafts") return;
|
|
46
|
+
const siteEnv = getSiteEnv(environment);
|
|
47
|
+
if (siteEnv !== "staging") {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`PAPYRUS_CONTENT_SOURCE=drafts requires SITE_ENV=staging; the resolved site environment is "${siteEnv}". Drafts must never be served from production or development.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { fetchAuthSession } from "aws-amplify/auth/server";
|
|
3
|
+
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
4
|
+
|
|
5
|
+
export type StagingAccess = "anonymous" | "forbidden" | "ok";
|
|
6
|
+
|
|
7
|
+
const STAGING_ALLOWED_GROUPS = ["editor", "admin"];
|
|
8
|
+
|
|
9
|
+
export function groupsFromIdTokenPayload(payload: Record<string, unknown> | undefined): string[] {
|
|
10
|
+
const raw = payload?.["cognito:groups"];
|
|
11
|
+
if (Array.isArray(raw)) return raw.filter((group): group is string => typeof group === "string");
|
|
12
|
+
if (typeof raw === "string" && raw) return [raw];
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function accessForGroups(groups: string[] | null): StagingAccess {
|
|
17
|
+
if (groups === null) return "anonymous";
|
|
18
|
+
return groups.some((group) => STAGING_ALLOWED_GROUPS.includes(group)) ? "ok" : "forbidden";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getStagingAccess(request: NextRequest, response: NextResponse): Promise<StagingAccess> {
|
|
22
|
+
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
23
|
+
const groups = await runWithAmplifyServerContext({
|
|
24
|
+
nextServerContext: { request, response },
|
|
25
|
+
operation: async (contextSpec) => {
|
|
26
|
+
try {
|
|
27
|
+
const session = await fetchAuthSession(contextSpec);
|
|
28
|
+
const idTokenPayload = session.tokens?.idToken?.payload as Record<string, unknown> | undefined;
|
|
29
|
+
if (!session.tokens?.idToken) return null;
|
|
30
|
+
return groupsFromIdTokenPayload(idTokenPayload);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return accessForGroups(groups);
|
|
37
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cookies } from "next/headers";
|
|
2
|
+
import { getUrl } from "aws-amplify/storage/server";
|
|
3
|
+
import { getAmplifyServerRuntime } from "./amplify-server-runtime";
|
|
4
|
+
import { getSiteEnv } from "./site-env";
|
|
5
|
+
|
|
6
|
+
export const PREVIEW_ROUTE_PREFIX = "/__preview";
|
|
7
|
+
export const PREVIEW_KEY_PREFIX = "preview/";
|
|
8
|
+
|
|
9
|
+
const PRESIGN_SECONDS = 60;
|
|
10
|
+
const NOT_FOUND_ERROR_NAMES = ["NotFound", "NoSuchKey"];
|
|
11
|
+
const PREVIEW_EXCLUDED_PREFIXES = ["/newsroom", "/api", "/_next", PREVIEW_ROUTE_PREFIX];
|
|
12
|
+
|
|
13
|
+
export function isStaticPreviewEnabled(environment: Record<string, string | undefined> = process.env): boolean {
|
|
14
|
+
return getSiteEnv(environment) === "staging" && (environment.PAPYRUS_STAGING_PREVIEW ?? "").trim() === "static";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function previewCandidateKeys(requestPath: string): string[] | null {
|
|
18
|
+
if (requestPath.includes("//")) return null;
|
|
19
|
+
let decoded: string;
|
|
20
|
+
try {
|
|
21
|
+
decoded = decodeURIComponent(requestPath);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (decoded.includes("//") || decoded.includes("\\") || decoded.includes("\0")) return null;
|
|
26
|
+
const segments = decoded.split("/").filter((segment) => segment !== "");
|
|
27
|
+
if (segments.some((segment) => segment === ".." || segment === ".")) return null;
|
|
28
|
+
if (segments.length === 0) return [`${PREVIEW_KEY_PREFIX}index.html`];
|
|
29
|
+
const joined = segments.join("/");
|
|
30
|
+
if (decoded.endsWith("/")) return [`${PREVIEW_KEY_PREFIX}${joined}/index.html`];
|
|
31
|
+
const base = `${PREVIEW_KEY_PREFIX}${joined}`;
|
|
32
|
+
return [base, `${base}.html`, `${base}/index.html`];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shouldRewriteToPreview(pathname: string): boolean {
|
|
36
|
+
if (pathname === "/favicon.ico" || pathname === "/robots.txt" || pathname.startsWith("/icon")) return false;
|
|
37
|
+
return !PREVIEW_EXCLUDED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isPreviewGatedPath(pathname: string): boolean {
|
|
41
|
+
return shouldRewriteToPreview(pathname) || pathname === PREVIEW_ROUTE_PREFIX || pathname.startsWith(`${PREVIEW_ROUTE_PREFIX}/`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function previewRewritePathname(pathname: string): string {
|
|
45
|
+
return `${PREVIEW_ROUTE_PREFIX}${pathname}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function previewRequestPath(pathname: string): string {
|
|
49
|
+
return pathname.slice(PREVIEW_ROUTE_PREFIX.length) || "/";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isNotFoundError(error: unknown): boolean {
|
|
53
|
+
const name = (error as { name?: string } | null)?.name ?? "";
|
|
54
|
+
return NOT_FOUND_ERROR_NAMES.includes(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function presignFirstExistingKey(candidateKeys: string[]): Promise<URL | null> {
|
|
58
|
+
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
59
|
+
return runWithAmplifyServerContext({
|
|
60
|
+
nextServerContext: { cookies },
|
|
61
|
+
operation: async (contextSpec) => {
|
|
62
|
+
for (const key of candidateKeys) {
|
|
63
|
+
try {
|
|
64
|
+
const { url } = await getUrl(contextSpec, {
|
|
65
|
+
path: key,
|
|
66
|
+
options: { validateObjectExistence: true, expiresIn: PRESIGN_SECONDS },
|
|
67
|
+
});
|
|
68
|
+
return url;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!isNotFoundError(error)) throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function notFound(): Response {
|
|
79
|
+
return new Response("Not found", { status: 404, headers: { "Cache-Control": "private, no-store" } });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function streamPreviewObject(pathname: string): Promise<Response> {
|
|
83
|
+
if (!isStaticPreviewEnabled()) return notFound();
|
|
84
|
+
const candidateKeys = previewCandidateKeys(previewRequestPath(pathname));
|
|
85
|
+
if (!candidateKeys) return notFound();
|
|
86
|
+
const presigned = await presignFirstExistingKey(candidateKeys);
|
|
87
|
+
if (!presigned) return notFound();
|
|
88
|
+
const upstream = await fetch(presigned);
|
|
89
|
+
if (!upstream.ok || !upstream.body) return notFound();
|
|
90
|
+
return new Response(upstream.body, {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
|
94
|
+
"Cache-Control": "private, no-store",
|
|
95
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type VideoMode = "preview" | "mp4";
|
|
2
|
+
|
|
3
|
+
function parseVideoMode(value: string | null | undefined): VideoMode | null {
|
|
4
|
+
if (value === "preview" || value === "mp4") return value;
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Server-safe mode from env only. Production always returns `"mp4"`. */
|
|
9
|
+
export function resolveVideoModeFromEnv(): VideoMode {
|
|
10
|
+
if (process.env.NODE_ENV === "production") return "mp4";
|
|
11
|
+
return parseVideoMode(process.env.NEXT_PUBLIC_PAPYRUS_VIDEO_MODE) ?? "preview";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Client-only `?video=` override. Returns null when absent or in production. */
|
|
15
|
+
export function resolveVideoModeFromSearch(search: string): VideoMode | null {
|
|
16
|
+
if (process.env.NODE_ENV === "production") return null;
|
|
17
|
+
const query = search.startsWith("?") ? search.slice(1) : search;
|
|
18
|
+
return parseVideoMode(new URLSearchParams(query).get("video"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Effective video mode for the reader.
|
|
23
|
+
* Production always uses `"mp4"`. In development, `?video=` overrides
|
|
24
|
+
* `NEXT_PUBLIC_PAPYRUS_VIDEO_MODE` (default `"preview"`).
|
|
25
|
+
*/
|
|
26
|
+
export function resolveVideoMode(): VideoMode {
|
|
27
|
+
if (process.env.NODE_ENV === "production") return "mp4";
|
|
28
|
+
if (typeof window !== "undefined") {
|
|
29
|
+
const fromSearch = resolveVideoModeFromSearch(window.location.search);
|
|
30
|
+
if (fromSearch) return fromSearch;
|
|
31
|
+
}
|
|
32
|
+
return resolveVideoModeFromEnv();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Server-safe gate for fetching VideoML DSL scripts.
|
|
37
|
+
* Always true in development so `?video=preview` and `?video=mp4` can both
|
|
38
|
+
* work without restarting the server. Production never fetches scripts.
|
|
39
|
+
*/
|
|
40
|
+
export function shouldFetchVideoScripts(): boolean {
|
|
41
|
+
return process.env.NODE_ENV === "development";
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type VideoElementAliases = Record<string, string>;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rewrites generic VideoML element names in a stored script to the publication's
|
|
5
|
+
* own scene components, e.g. `{ "quote-card": "acme-quote-card" }`.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeDevPreviewDsl(dsl: string, elementAliases: VideoElementAliases = {}): string {
|
|
8
|
+
let normalized = dsl;
|
|
9
|
+
for (const [generic, branded] of Object.entries(elementAliases)) {
|
|
10
|
+
const escaped = generic.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
normalized = normalized.replace(new RegExp(`(</?)${escaped}(?=[\\s/>])`, "g"), `$1${branded}`);
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type VideoScriptTargetKind = "article" | "edition";
|
|
17
|
+
|
|
18
|
+
export type VideoScriptRef = {
|
|
19
|
+
slug: string;
|
|
20
|
+
dsl: string;
|
|
21
|
+
targetKind: VideoScriptTargetKind;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function videomlItemSlug(targetSlug: string): string {
|
|
25
|
+
return `${targetSlug}--videoml`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseVideoScriptRef(item: {
|
|
29
|
+
slug: string;
|
|
30
|
+
editorial?: unknown;
|
|
31
|
+
}): VideoScriptRef | null {
|
|
32
|
+
const editorial = parseEditorial(item.editorial);
|
|
33
|
+
const videoScript = editorial?.videoScript;
|
|
34
|
+
if (!videoScript || typeof videoScript !== "object" || Array.isArray(videoScript)) return null;
|
|
35
|
+
|
|
36
|
+
const scriptRecord = videoScript as Record<string, unknown>;
|
|
37
|
+
const dsl = typeof scriptRecord.dsl === "string" ? scriptRecord.dsl.trim() : "";
|
|
38
|
+
if (!dsl) return null;
|
|
39
|
+
|
|
40
|
+
const target = scriptRecord.target;
|
|
41
|
+
const targetKind: VideoScriptTargetKind =
|
|
42
|
+
target && typeof target === "object" && !Array.isArray(target) && (target as Record<string, unknown>).kind === "edition"
|
|
43
|
+
? "edition"
|
|
44
|
+
: "article";
|
|
45
|
+
|
|
46
|
+
return { slug: item.slug, dsl, targetKind };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseEditorial(editorial: unknown): Record<string, unknown> | null {
|
|
50
|
+
if (!editorial) return null;
|
|
51
|
+
if (typeof editorial === "string") {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(editorial) as unknown;
|
|
54
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (typeof editorial === "object" && !Array.isArray(editorial)) {
|
|
60
|
+
return editorial as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|