@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +52 -70
  4. package/amplify/data/resource.ts +57 -70
  5. package/amplify/data/schema.js +52 -58
  6. package/amplify/data/schema.ts +57 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +149 -0
  12. package/amplify/functions/content-actions/requirements.txt +4 -0
  13. package/amplify/functions/content-actions/resource.js +40 -0
  14. package/amplify/functions/content-actions/resource.ts +54 -0
  15. package/amplify/functions/shared/python-bundle.js +17 -2
  16. package/amplify/functions/shared/python-bundle.ts +23 -2
  17. package/amplify/rebuild-trigger-settings.js +22 -0
  18. package/amplify/rebuild-trigger-settings.ts +42 -0
  19. package/amplify/site-backend-config.ts +12 -1
  20. package/amplify/site-backend.js +51 -27
  21. package/amplify/site-backend.ts +66 -34
  22. package/amplify/storage/resource.js +3 -0
  23. package/amplify/storage/resource.ts +3 -0
  24. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  25. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
  26. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  27. package/app/api/revalidate/route.ts +8 -7
  28. package/app/articles/[slug]/page.tsx +6 -4
  29. package/app/blog-rhythm.css +278 -0
  30. package/app/globals.css +22 -4
  31. package/app/layout.tsx +24 -12
  32. package/app/newsroom/articles/[id]/page.tsx +15 -0
  33. package/app/newsroom/articles/page.tsx +13 -0
  34. package/app/page.tsx +12 -4
  35. package/app/robots.ts +11 -0
  36. package/bin/papyrus-app.mjs +12 -2
  37. package/bin/papyrus-infra.mjs +74 -18
  38. package/components/archive-shell.tsx +2 -22
  39. package/components/article-video.tsx +247 -3
  40. package/components/blog-page-background.tsx +23 -6
  41. package/components/markus-ir-preview.tsx +300 -0
  42. package/components/news-desk-page.tsx +1 -4
  43. package/components/newspaper.tsx +6 -2
  44. package/components/newsroom-access-gate.tsx +66 -0
  45. package/components/newsroom-article-editor.tsx +741 -0
  46. package/components/newsroom-articles-view.tsx +129 -0
  47. package/components/newsroom-ops-overview.tsx +28 -11
  48. package/components/newsroom-ops-shell.tsx +3 -1
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +1 -59
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +28 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +94 -0
  61. package/infra/build-specs.ts +141 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/blog-feature-solver.ts +459 -0
  68. package/lib/blog-rhythm.ts +134 -0
  69. package/lib/brand-fonts-watch.ts +43 -0
  70. package/lib/brand-fonts.ts +112 -0
  71. package/lib/cached-content-repository.ts +14 -1
  72. package/lib/category-repository.ts +1 -93
  73. package/lib/content-actions-client.ts +98 -0
  74. package/lib/content-actions-demo.ts +190 -0
  75. package/lib/content-repository.ts +6 -0
  76. package/lib/content-source-context.ts +26 -0
  77. package/lib/content-types.ts +4 -0
  78. package/lib/edition-routes.ts +31 -29
  79. package/lib/empty-theme.css +1 -0
  80. package/lib/graphql-content-repository.ts +239 -43
  81. package/lib/layout-scenarios.ts +8 -8
  82. package/lib/newspaper-layout.ts +3 -2
  83. package/lib/newsroom-articles.ts +325 -0
  84. package/lib/newsroom-demo-articles.ts +90 -0
  85. package/lib/newsroom-media-backend.ts +142 -0
  86. package/lib/newsroom-media.ts +182 -0
  87. package/lib/newsroom-nav.ts +3 -0
  88. package/lib/papyrus-web-locations.ts +7 -6
  89. package/lib/presentation-footer.ts +3 -2
  90. package/lib/pretext-layout.ts +12 -3
  91. package/lib/reader-base-path.ts +24 -0
  92. package/lib/reader-static-params.ts +8 -0
  93. package/lib/renderer.ts +7 -0
  94. package/lib/revalidate-secret.ts +59 -0
  95. package/lib/root-route-redirect.ts +16 -0
  96. package/lib/site-brand-tenant.ts +3 -22
  97. package/lib/site-brand.ts +95 -42
  98. package/lib/site-env.ts +52 -0
  99. package/lib/staging-gate.ts +37 -0
  100. package/lib/staging-gated-path.ts +22 -0
  101. package/lib/staging-preview-object.ts +98 -0
  102. package/lib/video-mode.ts +42 -0
  103. package/lib/video-script.ts +77 -0
  104. package/middleware.ts +56 -42
  105. package/package.json +40 -1
  106. package/renderers/pretext/article-page.tsx +40 -9
  107. package/renderers/pretext/presentation-shared.tsx +113 -0
  108. package/renderers/pretext/presentation-shell.tsx +18 -112
  109. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  110. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  111. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  112. package/routes.manifest.json +37 -2
  113. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  114. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  115. package/scripts/videoml/bundle-common.mjs +129 -0
  116. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  117. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  118. package/scripts/videoml/preview/preview-timing.ts +42 -0
  119. package/scripts/videoml/preview.html +77 -0
  120. package/scripts/videoml/standard-entry.tsx +46 -0
  121. package/src/sync.mjs +30 -6
  122. package/src/with-papyrus.mjs +15 -2
  123. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  124. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  125. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  126. package/publications/anth_us/brand.ts +0 -68
  127. package/publications/anth_us/theme.css +0 -66
  128. package/publications/pilobol_us/brand.ts +0 -37
  129. package/publications/pilobol_us/theme.css +0 -140
  130. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  131. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  132. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  133. package/publications/threat_intelligence/brand.ts +0 -40
  134. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  135. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  136. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  137. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  138. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  139. package/publications/threat_intelligence/theme.css +0 -1440
  140. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
package/lib/site-brand.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import type { ComponentType } from "react";
1
2
  import type { EditionPresentationFormat } from "./content-types";
3
+ import type { Article, ArticleVideoAsset } from "./articles";
4
+ import type { BlogPageBackgroundProps } from "../components/blog-page-background";
5
+ import type { SiteBrandFont } from "./brand-fonts";
6
+ import type { PictogramFigureProps } from "../components/pictogram-figure";
2
7
  import papyrusSite from "papyrus-site";
3
- import { threatIntelligenceBrand } from "../publications/threat_intelligence/brand";
4
- import { pilobolUsBrand } from "../publications/pilobol_us/brand";
5
- import { anthUsBrand } from "../publications/anth_us/brand";
6
8
  import {
7
9
  DEFAULT_THEME_PACK_TOKENS,
8
10
  type HostingConfig,
@@ -17,6 +19,51 @@ import {
17
19
  /** Open string: a publication registers its own brand through `papyrus.config.ts` (`defineSite`). */
18
20
  export type SiteBrandId = string;
19
21
 
22
+ /** Brand-specific replacements for generic Pretext components. */
23
+ export type SiteBrandComponents = {
24
+ PictogramFigure?: ComponentType<PictogramFigureProps>;
25
+ BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
26
+ };
27
+
28
+ /**
29
+ * One line of a brand's header tagline: an emphasized lead and a plain tail,
30
+ * e.g. `{ emphasis: "Practical advice", tail: "for staying secure" }`.
31
+ */
32
+ export type SiteBrandTaglineLine = {
33
+ emphasis: string;
34
+ tail: string;
35
+ };
36
+
37
+ /**
38
+ * Blog presentation layout. `"classic"` (the default) is the original blog
39
+ * layout. `"rhythm"` snaps every height and gap to the vertical rhythm grid,
40
+ * floats featured images on the grid, draws rhythm rules between items, adds
41
+ * the eyebrow/tagline header and the rhythm overlay hotkey (Ctrl+=).
42
+ */
43
+ export type SiteBrandBlogLayout = "classic" | "rhythm";
44
+
45
+ /** Default scenario content a brand supplies for the layout lab. */
46
+ export type SiteBrandDemoEdition = {
47
+ title: string;
48
+ editionDate: string;
49
+ description: string;
50
+ articles: Article[];
51
+ suppressNewsDeskAppendix?: boolean;
52
+ editionVideo?: ArticleVideoAsset;
53
+ };
54
+
55
+ /**
56
+ * Video slot: how a publication supplies its own VideoML scene components.
57
+ * Paths are relative to the publication root.
58
+ */
59
+ export type SiteBrandVideo = {
60
+ /** TSX module that registers the brand's `@videoml` scene components via `window.Babulus.registerComponent`. */
61
+ bundleEntry: string;
62
+ /** DSL element aliases for the browser preview, e.g. `{ "quote-card": "acme-quote-card" }`. */
63
+ sceneComponents?: Record<string, string>;
64
+ rhythm?: Record<string, unknown>;
65
+ };
66
+
20
67
  export type SiteBrand = {
21
68
  id: SiteBrandId;
22
69
  appTitle: string;
@@ -24,6 +71,10 @@ export type SiteBrand = {
24
71
  mastheadTitle: string;
25
72
  mastheadSubtitle: string;
26
73
  mastheadTagline?: string;
74
+ /** Rhythm blog layout only: small line above the masthead title, e.g. "Acme Research". The first word is emphasized. */
75
+ mastheadEyebrow?: string;
76
+ /** Rhythm blog layout only: structured tagline; `mastheadTagline` stays the plain-text fallback. */
77
+ mastheadTaglineLines?: SiteBrandTaglineLine[];
27
78
  backToHomeLabel: string;
28
79
  articleTitleSuffix: string;
29
80
  placeholderByline: string;
@@ -51,17 +102,36 @@ export type SiteBrand = {
51
102
  rootRoute?: RootRouteConfig;
52
103
  /** Public URL prefix for newsroom routes. Default `/newsroom`; use `""` on a CMS-only subdomain. */
53
104
  newsroomBasePath?: string;
105
+ /** Public URL prefix for the Pretext reader (home, editions, articles, archive, settings). Default `""`; e.g. `/information`. */
106
+ readerBasePath?: string;
54
107
  opsChrome: OpsChrome;
55
108
  corpusKey: string;
56
109
  steeringConfigPath: string;
57
110
  newsroomSectionsConfigPath: string;
58
111
  analysisProfilesPath: string;
59
112
  publicationName: string;
113
+ components?: SiteBrandComponents;
114
+ /**
115
+ * Video player chrome. `"native"` (default) is the browser's controls;
116
+ * `"framed"` wraps the media in `.article-video__media` and renders a
117
+ * "Play Video" button with a seek bar (`.article-video__cta*`), styled by the publication CSS.
118
+ */
119
+ videoPlayer?: "native" | "framed";
120
+ /** Blog presentation layout. Default `"classic"`. See `SiteBrandBlogLayout`. */
121
+ blogLayout?: SiteBrandBlogLayout;
122
+ /**
123
+ * Brand font slot: families the root layout loads (Google Fonts stylesheet
124
+ * or self-hosted files) and exposes as CSS custom properties. See
125
+ * `lib/brand-fonts.ts` and docs/brand-slots.md.
126
+ */
127
+ fonts?: SiteBrandFont[];
128
+ demoEdition?: SiteBrandDemoEdition;
129
+ video?: SiteBrandVideo;
60
130
  };
61
131
 
62
132
  const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
63
133
 
64
- const BUILT_IN_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
134
+ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
65
135
  papyrus: {
66
136
  id: "papyrus",
67
137
  appTitle: "Papyrus",
@@ -88,63 +158,46 @@ const BUILT_IN_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
88
158
  analysisProfilesPath: "corpora/papyrus-analysis-profiles.yml",
89
159
  publicationName: "Anthus Threat Intelligence",
90
160
  },
91
- "threat-intelligence": threatIntelligenceBrand,
92
- "pilobol-us": pilobolUsBrand,
93
- "anth-us": anthUsBrand,
94
161
  };
95
162
 
96
163
  /**
97
- * Built-in reference brands plus whatever the publication registered in its
164
+ * The Papyrus reference brand plus whatever the publication registered in its
98
165
  * own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
99
166
  */
100
167
  const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
101
- ...BUILT_IN_SITE_BRANDS,
168
+ ...REFERENCE_SITE_BRANDS,
102
169
  ...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
103
170
  };
104
171
 
105
172
  export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
106
173
  if (!value) return null;
107
- const normalized = value.trim().toLowerCase().replace(/[._]/g, "-");
108
- if (!normalized) return null;
109
- if (normalized === "papyrus") return "papyrus";
110
- // NOTE: the bare alias `anthus` has resolved to `threat-intelligence` since
111
- // that brand was added and is kept pointing there so existing
112
- // PAPYRUS_SITE_BRAND values do not silently change meaning. The Anth.us
113
- // publication is therefore addressed as `anth-us` / `anth.us` / `anth_us`
114
- // only. Do not "tidy" this by moving `anthus` across.
115
- if (normalized === "threat-intelligence" || normalized === "threat-intel" || normalized === "anthus") {
116
- return "threat-intelligence";
117
- }
118
- if (normalized === "anth-us") {
119
- // `normalized` has already mapped `.` and `_` to `-`, so this covers
120
- // `anth.us` and `anth_us` too.
121
- return "anth-us";
122
- }
123
- // Bare `pilobol` is deliberately NOT an alias (f268e72): the brand name is
124
- // Pilobolus; Pilobol.us is the domain and `pilobol-us` the technical id.
125
- if (normalized === "pilobol-us" || normalized === "pilobolus") {
126
- return "pilobol-us";
127
- }
128
- // Registered brands (publication-owned `papyrus.config.ts`): exact id only.
129
- if (Object.prototype.hasOwnProperty.call(SITE_BRANDS, normalized)) return normalized;
130
- return null;
174
+ const trimmed = value.trim();
175
+ return Object.prototype.hasOwnProperty.call(SITE_BRANDS, trimmed) ? trimmed : null;
131
176
  }
132
177
 
133
- export function resolveSiteBrandId(
134
- raw: string | undefined | null = process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND ?? process.env.PAPYRUS_SITE_BRAND,
135
- ): SiteBrandId {
136
- return normalizeSiteBrandId(raw) ?? papyrusSite.defaultBrand ?? "papyrus";
178
+ function unknownBrandError(value: string): Error {
179
+ return new Error(`Unknown brand '${value}'. Registered: ${Object.keys(SITE_BRANDS).join(", ")}`);
137
180
  }
138
181
 
139
- /** Cookie set by middleware when `?brand=` is present (demo without rebuild). */
140
- export function resolveRuntimeSiteBrandId(
141
- cookieOverride: string | undefined | null = null,
182
+ export function resolveSiteBrandId(
183
+ raw: string | undefined | null = process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND ?? process.env.PAPYRUS_SITE_BRAND,
142
184
  ): SiteBrandId {
143
- return normalizeSiteBrandId(cookieOverride) ?? resolveSiteBrandId();
185
+ const requested = raw?.trim();
186
+ if (requested) {
187
+ const normalized = normalizeSiteBrandId(requested);
188
+ if (normalized === null) throw unknownBrandError(requested);
189
+ return normalized;
190
+ }
191
+ const fallback = papyrusSite.defaultBrand ?? "papyrus";
192
+ const normalizedFallback = normalizeSiteBrandId(fallback);
193
+ if (normalizedFallback === null) throw unknownBrandError(fallback);
194
+ return normalizedFallback;
144
195
  }
145
196
 
146
197
  export function getSiteBrand(id: SiteBrandId = resolveSiteBrandId()): SiteBrand {
147
- return SITE_BRANDS[id];
198
+ const brand = Object.prototype.hasOwnProperty.call(SITE_BRANDS, id) ? SITE_BRANDS[id] : undefined;
199
+ if (!brand) throw unknownBrandError(id);
200
+ return brand;
148
201
  }
149
202
 
150
203
  export const SITE_BRAND = getSiteBrand();
@@ -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,22 @@
1
+ import { normalizeReaderBasePath } from "./reader-base-path";
2
+
3
+ function isStaticOrApiPath(pathname: string): boolean {
4
+ return (
5
+ pathname.startsWith("/_next")
6
+ || pathname.startsWith("/api")
7
+ || pathname === "/favicon.ico"
8
+ || pathname.startsWith("/icon")
9
+ || /\.[a-zA-Z0-9]+$/.test(pathname)
10
+ );
11
+ }
12
+
13
+ export { isStaticOrApiPath };
14
+
15
+ export function isStagingGatedPath(pathname: string, readerBasePath: string = ""): boolean {
16
+ if (isStaticOrApiPath(pathname)) return false;
17
+ if (pathname === "/robots.txt") return false;
18
+ if (pathname === "/newsroom" || pathname.startsWith("/newsroom/")) return false;
19
+ const base = normalizeReaderBasePath(readerBasePath);
20
+ if (base) return pathname === base || pathname.startsWith(`${base}/`);
21
+ return true;
22
+ }
@@ -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,77 @@
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 const EDITION_OVERVIEW_VIDEO_KEY = "edition-overview";
25
+
26
+ export function collectVideoScriptTargets(
27
+ items: ReadonlyArray<{ slug: string; video?: unknown }>,
28
+ hasEditionVideo: boolean,
29
+ ): string[] {
30
+ const targets = new Set<string>();
31
+ if (hasEditionVideo) targets.add(EDITION_OVERVIEW_VIDEO_KEY);
32
+ for (const item of items) {
33
+ if (item.video) targets.add(item.slug);
34
+ }
35
+ return [...targets];
36
+ }
37
+
38
+ export function videomlItemSlug(targetSlug: string): string {
39
+ return `${targetSlug}--videoml`;
40
+ }
41
+
42
+ export function parseVideoScriptRef(item: {
43
+ slug: string;
44
+ editorial?: unknown;
45
+ }): VideoScriptRef | null {
46
+ const editorial = parseEditorial(item.editorial);
47
+ const videoScript = editorial?.videoScript;
48
+ if (!videoScript || typeof videoScript !== "object" || Array.isArray(videoScript)) return null;
49
+
50
+ const scriptRecord = videoScript as Record<string, unknown>;
51
+ const dsl = typeof scriptRecord.dsl === "string" ? scriptRecord.dsl.trim() : "";
52
+ if (!dsl) return null;
53
+
54
+ const target = scriptRecord.target;
55
+ const targetKind: VideoScriptTargetKind =
56
+ target && typeof target === "object" && !Array.isArray(target) && (target as Record<string, unknown>).kind === "edition"
57
+ ? "edition"
58
+ : "article";
59
+
60
+ return { slug: item.slug, dsl, targetKind };
61
+ }
62
+
63
+ function parseEditorial(editorial: unknown): Record<string, unknown> | null {
64
+ if (!editorial) return null;
65
+ if (typeof editorial === "string") {
66
+ try {
67
+ const parsed = JSON.parse(editorial) as unknown;
68
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ if (typeof editorial === "object" && !Array.isArray(editorial)) {
74
+ return editorial as Record<string, unknown>;
75
+ }
76
+ return null;
77
+ }
package/middleware.ts CHANGED
@@ -1,49 +1,39 @@
1
1
  import type { NextRequest } from "next/server";
2
2
  import { NextResponse } from "next/server";
3
- import { getSiteBrand, normalizeSiteBrandId, resolveSiteBrandId } from "./lib/site-brand";
3
+ import { getSiteBrand } from "./lib/site-brand";
4
+ import { getReaderBasePath } from "./lib/reader-base-path";
5
+ import { isStagingGatedPath, isStaticOrApiPath } from "./lib/staging-gated-path";
6
+ import { getSiteEnv, isIndexable } from "./lib/site-env";
7
+ import { getStagingAccess } from "./lib/staging-gate";
8
+ import {
9
+ isPreviewGatedPath,
10
+ isStaticPreviewEnabled,
11
+ previewRewritePathname,
12
+ shouldRewriteToPreview,
13
+ } from "./lib/staging-preview-object";
4
14
 
5
- const BRAND_OVERRIDE_COOKIE = "papyrus-site-brand-override";
6
-
7
- function usesNewsroomRootPaths(request: NextRequest): boolean {
8
- const cookieBrand = request.cookies.get(BRAND_OVERRIDE_COOKIE)?.value;
9
- const brandId = normalizeSiteBrandId(cookieBrand)
10
- ?? normalizeSiteBrandId(request.nextUrl.searchParams.get("brand"))
11
- ?? resolveSiteBrandId();
12
- // Brand property (not a hard-coded id) so publication-registered brands work.
13
- return getSiteBrand(brandId).rootRoute?.kind === "newsroom" && getSiteBrand(brandId).newsroomBasePath === "";
15
+ function usesNewsroomRootPaths(): boolean {
16
+ const brand = getSiteBrand();
17
+ return brand.rootRoute?.kind === "newsroom" && brand.newsroomBasePath === "";
14
18
  }
15
19
 
16
- function isStaticOrApiPath(pathname: string): boolean {
17
- return (
18
- pathname.startsWith("/_next")
19
- || pathname.startsWith("/api")
20
- || pathname === "/favicon.ico"
21
- || pathname.startsWith("/icon")
22
- || /\.[a-zA-Z0-9]+$/.test(pathname)
23
- );
20
+ function isStagingGatedPathForBrand(pathname: string): boolean {
21
+ return isStagingGatedPath(pathname, getReaderBasePath());
24
22
  }
25
23
 
26
- export function middleware(request: NextRequest) {
27
- const brandParam = request.nextUrl.searchParams.get("brand");
28
- const brandId = normalizeSiteBrandId(brandParam);
29
- let response: NextResponse | null = null;
30
-
31
- if (brandId) {
32
- response = NextResponse.next();
33
- response.cookies.set(BRAND_OVERRIDE_COOKIE, brandId, {
34
- path: "/",
35
- maxAge: 60 * 60 * 24 * 30,
36
- sameSite: "lax",
37
- });
24
+ function routeRequest(request: NextRequest): NextResponse {
25
+ if (isStaticPreviewEnabled() && shouldRewriteToPreview(request.nextUrl.pathname)) {
26
+ const rewriteUrl = request.nextUrl.clone();
27
+ rewriteUrl.pathname = previewRewritePathname(request.nextUrl.pathname);
28
+ return NextResponse.rewrite(rewriteUrl);
38
29
  }
39
-
40
- if (!usesNewsroomRootPaths(request)) {
41
- return response ?? NextResponse.next();
30
+ if (!usesNewsroomRootPaths()) {
31
+ return NextResponse.next();
42
32
  }
43
33
 
44
34
  const { pathname } = request.nextUrl;
45
35
  if (isStaticOrApiPath(pathname)) {
46
- return response ?? NextResponse.next();
36
+ return NextResponse.next();
47
37
  }
48
38
 
49
39
  if (pathname === "/newsroom" || pathname === "/newsroom/") {
@@ -55,24 +45,48 @@ export function middleware(request: NextRequest) {
55
45
  }
56
46
 
57
47
  if (pathname === "/" || pathname === "") {
58
- return response ?? NextResponse.next();
48
+ return NextResponse.next();
59
49
  }
60
50
 
61
51
  if (!pathname.startsWith("/newsroom")) {
62
52
  const rewriteUrl = request.nextUrl.clone();
63
53
  rewriteUrl.pathname = `/newsroom${pathname}`;
64
- const rewrite = NextResponse.rewrite(rewriteUrl);
65
- if (response) {
66
- for (const cookie of response.cookies.getAll()) {
67
- rewrite.cookies.set(cookie);
68
- }
54
+ return NextResponse.rewrite(rewriteUrl);
55
+ }
56
+
57
+ return NextResponse.next();
58
+ }
59
+
60
+ export async function middleware(request: NextRequest): Promise<NextResponse> {
61
+ let sessionResponse: NextResponse | null = null;
62
+ const { pathname } = request.nextUrl;
63
+ const gated = isStaticPreviewEnabled() ? isPreviewGatedPath(pathname) : isStagingGatedPathForBrand(pathname);
64
+ if (getSiteEnv() === "staging" && gated) {
65
+ sessionResponse = NextResponse.next();
66
+ const access = await getStagingAccess(request, sessionResponse);
67
+ if (access === "anonymous") {
68
+ return withRobotsHeader(NextResponse.redirect(new URL("/newsroom", request.url)));
69
+ }
70
+ if (access === "forbidden") {
71
+ return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
69
72
  }
70
- return rewrite;
71
73
  }
72
74
 
73
- return response ?? NextResponse.next();
75
+ const response = routeRequest(request);
76
+ if (sessionResponse) {
77
+ for (const cookie of sessionResponse.headers.getSetCookie()) {
78
+ response.headers.append("set-cookie", cookie);
79
+ }
80
+ }
81
+ return withRobotsHeader(response);
82
+ }
83
+
84
+ function withRobotsHeader(response: NextResponse): NextResponse {
85
+ if (!isIndexable()) response.headers.set("X-Robots-Tag", "noindex, nofollow");
86
+ return response;
74
87
  }
75
88
 
76
89
  export const config = {
90
+ runtime: "nodejs",
77
91
  matcher: ["/((?!_next/static|_next/image|favicon.ico|icon).*)"],
78
92
  };