@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.
Files changed (40) hide show
  1. package/amplify/storage/resource.js +3 -0
  2. package/amplify/storage/resource.ts +3 -0
  3. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  4. package/app/globals.css +19 -0
  5. package/app/layout.tsx +4 -0
  6. package/app/robots.ts +11 -0
  7. package/bin/papyrus-app.mjs +12 -2
  8. package/bin/papyrus-infra.mjs +72 -16
  9. package/components/article-video.tsx +112 -3
  10. package/components/staging-banner.tsx +11 -0
  11. package/infra/amplify-app-shell.js +178 -80
  12. package/infra/amplify-app-shell.ts +217 -113
  13. package/infra/build-specs.js +86 -0
  14. package/infra/build-specs.ts +132 -0
  15. package/infra/github-oidc-provider.js +24 -0
  16. package/infra/github-oidc-provider.ts +30 -0
  17. package/infra/index.js +3 -0
  18. package/infra/site-config.js +155 -0
  19. package/infra/site-config.ts +200 -0
  20. package/lib/cached-content-repository.ts +2 -1
  21. package/lib/content-repository.ts +3 -0
  22. package/lib/content-source-context.ts +16 -0
  23. package/lib/graphql-content-repository.ts +182 -37
  24. package/lib/site-brand.ts +13 -0
  25. package/lib/site-env.ts +52 -0
  26. package/lib/staging-gate.ts +37 -0
  27. package/lib/staging-preview-object.ts +98 -0
  28. package/lib/video-mode.ts +42 -0
  29. package/lib/video-script.ts +63 -0
  30. package/middleware.ts +51 -1
  31. package/package.json +11 -1
  32. package/routes.manifest.json +11 -1
  33. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  34. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  35. package/scripts/videoml/bundle-common.mjs +129 -0
  36. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  37. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  38. package/scripts/videoml/preview/preview-timing.ts +42 -0
  39. package/scripts/videoml/preview.html +77 -0
  40. 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 ARTICLE_TYPE_STATUS = "article#published";
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
- return runWithAmplifyServerContext({
139
- nextServerContext: null,
140
- operation: async () => {
141
- readerContextClient = generateClient<Schema>({ authMode: AUTH_MODE });
142
- try {
143
- return await operation();
144
- } finally {
145
- readerContextClient = null;
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 !== "article" || item.status !== "published") return undefined;
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(ARTICLE_TYPE_STATUS);
234
+ const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
222
235
  return items
223
- .filter((item) => item.type === "article" && item.status === "published")
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: AUTH_MODE });
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 !== PUBLISHED_STATUS) return null;
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 !== PUBLISHED_STATUS) return null;
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 !== PUBLISHED_STATUS) continue;
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() as unknown as {
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
- function getPublishedEditionModel() {
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: AUTH_MODE });
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: AUTH_MODE,
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: AUTH_MODE,
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: AUTH_MODE,
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';
@@ -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
+ }