@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.
- package/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- 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/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-page.tsx +1 -4
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -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 +273 -0
- package/infra/site-config.ts +336 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +14 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +6 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +239 -43
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +56 -42
- package/package.json +40 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +37 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +15 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
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
|
|
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
|
-
*
|
|
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
|
-
...
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
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,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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
};
|