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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/amplify/data/resource.js +26 -0
  2. package/amplify/data/resource.ts +30 -0
  3. package/amplify/data/schema.js +26 -0
  4. package/amplify/data/schema.ts +30 -0
  5. package/amplify/functions/content-actions/handler.py +107 -0
  6. package/amplify/functions/content-actions/requirements.txt +4 -0
  7. package/amplify/functions/content-actions/resource.js +40 -0
  8. package/amplify/functions/content-actions/resource.ts +54 -0
  9. package/amplify/functions/shared/python-bundle.js +17 -2
  10. package/amplify/functions/shared/python-bundle.ts +23 -2
  11. package/amplify/site-backend.js +10 -0
  12. package/amplify/site-backend.ts +15 -0
  13. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -2
  14. package/app/globals.css +1 -4
  15. package/app/layout.tsx +8 -12
  16. package/app/robots.ts +11 -0
  17. package/bin/papyrus-infra.mjs +46 -16
  18. package/components/news-desk-page.tsx +1 -4
  19. package/components/staging-banner.tsx +11 -0
  20. package/infra/amplify-app-shell.js +123 -80
  21. package/infra/amplify-app-shell.ts +139 -113
  22. package/infra/build-specs.js +86 -0
  23. package/infra/build-specs.ts +132 -0
  24. package/infra/index.js +2 -0
  25. package/infra/site-config.js +125 -0
  26. package/infra/site-config.ts +163 -0
  27. package/lib/cached-content-repository.ts +2 -1
  28. package/lib/content-actions-client.ts +98 -0
  29. package/lib/content-repository.ts +3 -0
  30. package/lib/content-source-context.ts +16 -0
  31. package/lib/empty-theme.css +1 -0
  32. package/lib/graphql-content-repository.ts +182 -37
  33. package/lib/layout-scenarios.ts +8 -8
  34. package/lib/site-brand-tenant.ts +3 -22
  35. package/lib/site-brand.ts +44 -42
  36. package/lib/site-env.ts +52 -0
  37. package/lib/staging-gate.ts +37 -0
  38. package/middleware.ts +46 -34
  39. package/package.json +6 -1
  40. package/renderers/pretext/article-page.tsx +1 -6
  41. package/renderers/pretext/presentation-shell.tsx +2 -9
  42. package/routes.manifest.json +1 -1
  43. package/src/with-papyrus.mjs +15 -2
  44. package/publications/anth_us/brand.ts +0 -68
  45. package/publications/anth_us/theme.css +0 -66
  46. package/publications/pilobol_us/brand.ts +0 -37
  47. package/publications/pilobol_us/theme.css +0 -140
  48. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  49. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  50. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  51. package/publications/threat_intelligence/brand.ts +0 -40
  52. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  53. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  54. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  55. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  56. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  57. package/publications/threat_intelligence/theme.css +0 -1440
  58. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
@@ -5,7 +5,6 @@ import { createDefaultEditionLayoutPlan, type EditionLayoutPlan } from "./layout
5
5
  import { articleToPublicationItem, cloneArticle } from "./publication-items";
6
6
  import { PILOBOL_SAMPLE_SCENARIO_ID, createPilobolSampleEditionContent } from "./pilobol-sample";
7
7
  import { SITE_BRAND } from "./site-brand";
8
- import threatIntelligenceSeedContent from "../publications/threat_intelligence/seed/seed-edition-content.json";
9
8
 
10
9
  export type LayoutScenario = EditionContent & {
11
10
  source: "scenario";
@@ -190,7 +189,8 @@ function getDefaultScenarioSeed(): {
190
189
  editionVideo?: ArticleVideoAsset;
191
190
  newsDeskAppendix?: NewsDeskAppendix | null;
192
191
  } {
193
- if (SITE_BRAND.id !== "threat-intelligence") {
192
+ const demoEdition = SITE_BRAND.demoEdition;
193
+ if (!demoEdition) {
194
194
  return {
195
195
  title: "Current Edition",
196
196
  editionDate,
@@ -201,12 +201,12 @@ function getDefaultScenarioSeed(): {
201
201
  }
202
202
 
203
203
  return {
204
- title: threatIntelligenceSeedContent.title,
205
- editionDate: threatIntelligenceSeedContent.publishDate,
206
- description: threatIntelligenceSeedContent.description,
207
- articles: threatIntelligenceSeedContent.articles as Article[],
208
- suppressNewsDeskAppendix: threatIntelligenceSeedContent.suppressNewsDeskAppendix === true,
209
- editionVideo: threatIntelligenceSeedContent.video as ArticleVideoAsset | undefined,
204
+ title: demoEdition.title,
205
+ editionDate: demoEdition.editionDate,
206
+ description: demoEdition.description,
207
+ articles: demoEdition.articles,
208
+ suppressNewsDeskAppendix: demoEdition.suppressNewsDeskAppendix === true,
209
+ editionVideo: demoEdition.editionVideo,
210
210
  newsDeskAppendix: null,
211
211
  };
212
212
  }
@@ -1,27 +1,8 @@
1
1
  import path from "node:path";
2
- import {
3
- getSiteBrand,
4
- resolveRuntimeSiteBrandId,
5
- resolveSiteBrandId,
6
- type SiteBrand,
7
- type SiteBrandId,
8
- } from "./site-brand";
2
+ import { getSiteBrand, resolveSiteBrandId, type SiteBrand, type SiteBrandId } from "./site-brand";
9
3
 
10
- export const BRAND_OVERRIDE_COOKIE = "papyrus-site-brand-override";
11
-
12
- export function resolveActiveSiteBrandId(
13
- cookieOverride: string | undefined | null = null,
14
- envBrandId: SiteBrandId = resolveSiteBrandId(),
15
- ): SiteBrandId {
16
- return resolveRuntimeSiteBrandId(cookieOverride) ?? envBrandId;
17
- }
18
-
19
- export function resolveActiveSiteBrand(
20
- cookieOverride: string | undefined | null = null,
21
- brandId?: SiteBrandId,
22
- ): SiteBrand {
23
- const resolvedId = brandId ?? resolveActiveSiteBrandId(cookieOverride);
24
- return getSiteBrand(resolvedId);
4
+ export function resolveActiveSiteBrand(brandId?: SiteBrandId): SiteBrand {
5
+ return getSiteBrand(brandId ?? resolveSiteBrandId());
25
6
  }
26
7
 
27
8
  export function resolveBrandConfigPath(relativePath: string): string {
package/lib/site-brand.ts CHANGED
@@ -1,8 +1,9 @@
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 { PictogramFigureProps } from "../components/pictogram-figure";
2
6
  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
7
  import {
7
8
  DEFAULT_THEME_PACK_TOKENS,
8
9
  type HostingConfig,
@@ -17,6 +18,22 @@ import {
17
18
  /** Open string: a publication registers its own brand through `papyrus.config.ts` (`defineSite`). */
18
19
  export type SiteBrandId = string;
19
20
 
21
+ /** Brand-specific replacements for generic Pretext components. */
22
+ export type SiteBrandComponents = {
23
+ PictogramFigure?: ComponentType<PictogramFigureProps>;
24
+ BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
25
+ };
26
+
27
+ /** Default scenario content a brand supplies for the layout lab. */
28
+ export type SiteBrandDemoEdition = {
29
+ title: string;
30
+ editionDate: string;
31
+ description: string;
32
+ articles: Article[];
33
+ suppressNewsDeskAppendix?: boolean;
34
+ editionVideo?: ArticleVideoAsset;
35
+ };
36
+
20
37
  export type SiteBrand = {
21
38
  id: SiteBrandId;
22
39
  appTitle: string;
@@ -57,11 +74,13 @@ export type SiteBrand = {
57
74
  newsroomSectionsConfigPath: string;
58
75
  analysisProfilesPath: string;
59
76
  publicationName: string;
77
+ components?: SiteBrandComponents;
78
+ demoEdition?: SiteBrandDemoEdition;
60
79
  };
61
80
 
62
81
  const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
63
82
 
64
- const BUILT_IN_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
83
+ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
65
84
  papyrus: {
66
85
  id: "papyrus",
67
86
  appTitle: "Papyrus",
@@ -88,63 +107,46 @@ const BUILT_IN_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
88
107
  analysisProfilesPath: "corpora/papyrus-analysis-profiles.yml",
89
108
  publicationName: "Anthus Threat Intelligence",
90
109
  },
91
- "threat-intelligence": threatIntelligenceBrand,
92
- "pilobol-us": pilobolUsBrand,
93
- "anth-us": anthUsBrand,
94
110
  };
95
111
 
96
112
  /**
97
- * Built-in reference brands plus whatever the publication registered in its
113
+ * The Papyrus reference brand plus whatever the publication registered in its
98
114
  * own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
99
115
  */
100
116
  const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
101
- ...BUILT_IN_SITE_BRANDS,
117
+ ...REFERENCE_SITE_BRANDS,
102
118
  ...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
103
119
  };
104
120
 
105
121
  export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
106
122
  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;
123
+ const trimmed = value.trim();
124
+ return Object.prototype.hasOwnProperty.call(SITE_BRANDS, trimmed) ? trimmed : null;
131
125
  }
132
126
 
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";
127
+ function unknownBrandError(value: string): Error {
128
+ return new Error(`Unknown brand '${value}'. Registered: ${Object.keys(SITE_BRANDS).join(", ")}`);
137
129
  }
138
130
 
139
- /** Cookie set by middleware when `?brand=` is present (demo without rebuild). */
140
- export function resolveRuntimeSiteBrandId(
141
- cookieOverride: string | undefined | null = null,
131
+ export function resolveSiteBrandId(
132
+ raw: string | undefined | null = process.env.NEXT_PUBLIC_PAPYRUS_SITE_BRAND ?? process.env.PAPYRUS_SITE_BRAND,
142
133
  ): SiteBrandId {
143
- return normalizeSiteBrandId(cookieOverride) ?? resolveSiteBrandId();
134
+ const requested = raw?.trim();
135
+ if (requested) {
136
+ const normalized = normalizeSiteBrandId(requested);
137
+ if (normalized === null) throw unknownBrandError(requested);
138
+ return normalized;
139
+ }
140
+ const fallback = papyrusSite.defaultBrand ?? "papyrus";
141
+ const normalizedFallback = normalizeSiteBrandId(fallback);
142
+ if (normalizedFallback === null) throw unknownBrandError(fallback);
143
+ return normalizedFallback;
144
144
  }
145
145
 
146
146
  export function getSiteBrand(id: SiteBrandId = resolveSiteBrandId()): SiteBrand {
147
- return SITE_BRANDS[id];
147
+ const brand = Object.prototype.hasOwnProperty.call(SITE_BRANDS, id) ? SITE_BRANDS[id] : undefined;
148
+ if (!brand) throw unknownBrandError(id);
149
+ return brand;
148
150
  }
149
151
 
150
152
  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
+ }
package/middleware.ts CHANGED
@@ -1,16 +1,12 @@
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 { getSiteEnv, isIndexable } from "./lib/site-env";
5
+ import { getStagingAccess } from "./lib/staging-gate";
4
6
 
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 === "";
7
+ function usesNewsroomRootPaths(): boolean {
8
+ const brand = getSiteBrand();
9
+ return brand.rootRoute?.kind === "newsroom" && brand.newsroomBasePath === "";
14
10
  }
15
11
 
16
12
  function isStaticOrApiPath(pathname: string): boolean {
@@ -23,27 +19,21 @@ function isStaticOrApiPath(pathname: string): boolean {
23
19
  );
24
20
  }
25
21
 
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
- });
38
- }
22
+ function isStagingGatedPath(pathname: string): boolean {
23
+ if (isStaticOrApiPath(pathname)) return false;
24
+ if (pathname === "/robots.txt") return false;
25
+ if (pathname === "/newsroom" || pathname.startsWith("/newsroom/")) return false;
26
+ return true;
27
+ }
39
28
 
40
- if (!usesNewsroomRootPaths(request)) {
41
- return response ?? NextResponse.next();
29
+ function routeRequest(request: NextRequest): NextResponse {
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,46 @@ 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
+ if (getSiteEnv() === "staging" && isStagingGatedPath(request.nextUrl.pathname)) {
63
+ sessionResponse = NextResponse.next();
64
+ const access = await getStagingAccess(request, sessionResponse);
65
+ if (access === "anonymous") {
66
+ return withRobotsHeader(NextResponse.redirect(new URL("/newsroom", request.url)));
67
+ }
68
+ if (access === "forbidden") {
69
+ return withRobotsHeader(new NextResponse("Staging is limited to editors and admins.", { status: 403 }));
70
+ }
71
+ }
72
+
73
+ const response = routeRequest(request);
74
+ if (sessionResponse) {
75
+ for (const cookie of sessionResponse.headers.getSetCookie()) {
76
+ response.headers.append("set-cookie", cookie);
69
77
  }
70
- return rewrite;
71
78
  }
79
+ return withRobotsHeader(response);
80
+ }
72
81
 
73
- return response ?? NextResponse.next();
82
+ function withRobotsHeader(response: NextResponse): NextResponse {
83
+ if (!isIndexable()) response.headers.set("X-Robots-Tag", "noindex, nofollow");
84
+ return response;
74
85
  }
75
86
 
76
87
  export const config = {
88
+ runtime: "nodejs",
77
89
  matcher: ["/((?!_next/static|_next/image|favicon.ico|icon).*)"],
78
90
  };
package/package.json CHANGED
@@ -95,6 +95,7 @@
95
95
  "./components/reader-theme-control": "./components/reader-theme-control.tsx",
96
96
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
97
97
  "./components/settings-page": "./components/settings-page.tsx",
98
+ "./components/staging-banner": "./components/staging-banner.tsx",
98
99
  "./components/topic-steering-workspace": "./components/topic-steering-workspace.tsx",
99
100
  "./components/ui/badge": "./components/ui/badge.tsx",
100
101
  "./components/ui/button-group": "./components/ui/button-group.tsx",
@@ -126,8 +127,10 @@
126
127
  "./lib/category-repository": "./lib/category-repository.ts",
127
128
  "./lib/console-chat-client": "./lib/console-chat-client.ts",
128
129
  "./lib/console-web-context": "./lib/console-web-context.ts",
130
+ "./lib/content-actions-client": "./lib/content-actions-client.ts",
129
131
  "./lib/content-load-timing": "./lib/content-load-timing.ts",
130
132
  "./lib/content-repository": "./lib/content-repository.ts",
133
+ "./lib/content-source-context": "./lib/content-source-context.ts",
131
134
  "./lib/content-types": "./lib/content-types.ts",
132
135
  "./lib/define-site": "./lib/define-site.ts",
133
136
  "./lib/demo-amplify-outputs": "./lib/demo-amplify-outputs.ts",
@@ -180,8 +183,10 @@
180
183
  "./lib/semantic-graph": "./lib/semantic-graph.ts",
181
184
  "./lib/site-brand-tenant": "./lib/site-brand-tenant.ts",
182
185
  "./lib/site-brand": "./lib/site-brand.ts",
186
+ "./lib/site-env": "./lib/site-env.ts",
183
187
  "./lib/site-renderer": "./lib/site-renderer.ts",
184
188
  "./lib/site-stack": "./lib/site-stack.ts",
189
+ "./lib/staging-gate": "./lib/staging-gate.ts",
185
190
  "./lib/themed-image": "./lib/themed-image.ts",
186
191
  "./lib/utils": "./lib/utils.ts",
187
192
  "./renderers/markus/index": "./renderers/markus/index.ts",
@@ -256,7 +261,7 @@
256
261
  "optional": true
257
262
  }
258
263
  },
259
- "version": "1.0.0-next.2",
264
+ "version": "1.0.0-next.4",
260
265
  "dependencies": {
261
266
  "@aws-amplify/adapter-nextjs": "^1.7.3",
262
267
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -7,15 +7,10 @@ import { getPublicationItemVideoAsset } from "../../lib/publication-items";
7
7
  import type { ArticlePageEditionFooter, RenderArticleProps, RenderItemProps } from "../../lib/renderer";
8
8
  import { SITE_BRAND } from "../../lib/site-brand";
9
9
  import { ArticleVideoFigure } from "../../components/article-video";
10
- import { PictogramFigure as ThreatIntelligencePictogramFigure } from "../../publications/threat_intelligence/pictograms/figure";
11
10
  import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
12
11
  import { PresentationFooter } from "../../components/presentation-footer";
13
12
 
14
- // Threat Intelligence supplies its own pictogram figure media; any other
15
- // publication gets the generic fallback (components/pictogram-figure.tsx).
16
- // Same decoupling as renderers/pretext/presentation-shell.tsx -- see
17
- // docs/pluggable-publishers.md section 2.6 and PPY-d79ff2.
18
- const PictogramFigure = SITE_BRAND.id === "threat-intelligence" ? ThreatIntelligencePictogramFigure : GenericPictogramFigure;
13
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
19
14
 
20
15
  export type { ArticlePageEditionFooter };
21
16
 
@@ -18,21 +18,14 @@ import type { ArticleVideoAsset } from "../../lib/articles";
18
18
  import type { PresentationTarget, RenderEditionProps } from "../../lib/renderer";
19
19
  import { SITE_BRAND, enforcePresentation, getDefaultPretextLayout } from "../../lib/site-brand";
20
20
  import { ArticleVideoFigure } from "../../components/article-video";
21
- import { BlogPageBackground as ThreatIntelligenceBlogPageBackground } from "../../publications/threat_intelligence/blog-defense/page-background";
22
21
  import { BlogPageBackground as GenericBlogPageBackground } from "../../components/blog-page-background";
23
22
  import { Newspaper } from "../../components/newspaper";
24
- import { PictogramFigure as ThreatIntelligencePictogramFigure } from "../../publications/threat_intelligence/pictograms/figure";
25
23
  import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
26
24
  import { PresentationFooter } from "../../components/presentation-footer";
27
25
  import { readLocalReaderSettings, resolveReaderSettings, subscribeReaderSettingsChanges } from "../../components/reader-settings";
28
26
 
29
- // Threat Intelligence supplies its own blog background and figure media; any
30
- // other publication gets the generic fallback (components/). This is the
31
- // coupling docs/pluggable-publishers.md section 2.6 flags, decoupled here
32
- // per PPY-d79ff2 by making the choice publication-conditional instead of
33
- // hardcoded.
34
- const BlogPageBackground = SITE_BRAND.id === "threat-intelligence" ? ThreatIntelligenceBlogPageBackground : GenericBlogPageBackground;
35
- const PictogramFigure = SITE_BRAND.id === "threat-intelligence" ? ThreatIntelligencePictogramFigure : GenericPictogramFigure;
27
+ const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
28
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
36
29
 
37
30
  export type { PresentationTarget };
38
31
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.2",
2
+ "version": "1.0.0-next.4",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
@@ -1,10 +1,14 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
2
4
 
3
5
  /**
4
6
  * Wrap a publication repo's Next config for Papyrus (SPIKE, PPY-82be6c).
5
7
  * - transpile the package (it ships TypeScript source),
6
- * - alias `papyrus-site` -> ./papyrus.config.ts and
7
- * `papyrus-amplify-outputs` -> ./amplify_outputs.json (both webpack and turbopack),
8
+ * - alias `papyrus-site` -> ./papyrus.config.ts,
9
+ * `papyrus-amplify-outputs` -> ./amplify_outputs.json and
10
+ * `papyrus-site-theme` -> ./publication/theme.css when it exists, else the shipped
11
+ * empty lib/empty-theme.css (both webpack and turbopack),
8
12
  * - carry Papyrus's own server-external packages and image settings.
9
13
  */
10
14
  const SERVER_EXTERNAL = [
@@ -18,11 +22,19 @@ const SERVER_EXTERNAL = [
18
22
  "@aws-amplify/data-schema",
19
23
  ];
20
24
 
25
+ const EMPTY_THEME = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../lib/empty-theme.css");
26
+
21
27
  export function withPapyrus(nextConfig = {}, options = {}) {
22
28
  const root = options.root ?? process.cwd();
29
+ const hasPublicationTheme = fs.existsSync(path.join(root, "publication/theme.css"));
30
+ const toProjectRelative = (absolute) => {
31
+ const relative = path.relative(root, absolute).split(path.sep).join("/");
32
+ return relative.startsWith(".") ? relative : `./${relative}`;
33
+ };
23
34
  const aliases = {
24
35
  "papyrus-site": path.join(root, "papyrus.config.ts"),
25
36
  "papyrus-amplify-outputs": path.join(root, "amplify_outputs.json"),
37
+ "papyrus-site-theme": hasPublicationTheme ? path.join(root, "publication/theme.css") : EMPTY_THEME,
26
38
  };
27
39
  return {
28
40
  distDir: process.env.NEXT_DIST_DIR || ".next",
@@ -37,6 +49,7 @@ export function withPapyrus(nextConfig = {}, options = {}) {
37
49
  ...nextConfig.turbopack?.resolveAlias,
38
50
  "papyrus-site": "./papyrus.config.ts",
39
51
  "papyrus-amplify-outputs": "./amplify_outputs.json",
52
+ "papyrus-site-theme": toProjectRelative(hasPublicationTheme ? path.join(root, "publication/theme.css") : EMPTY_THEME),
40
53
  },
41
54
  },
42
55
  webpack(config, context) {
@@ -1,68 +0,0 @@
1
- import type { SiteBrand } from "../../lib/site-brand";
2
- import { ANTH_US_THEME_PACK_TOKENS } from "../../lib/site-stack";
3
-
4
- /**
5
- * Site definition for the Anth.us publication on Papyrus.
6
- *
7
- * This is the **site**: masthead, theme pack, corpus wiring, deployment
8
- * metadata. It is a sibling of `publications/anthus/`, which is the
9
- * **editorial identity** (`style-profile.yml`, `editorial-rewrite-skill.yml`,
10
- * `reference-samples/`) and is not touched by this file.
11
- *
12
- * Every string below is read off the live Gatsby site at `AnthusAI/Anth.us`:
13
- * `gatsby-config.mjs` `siteMetadata` for the title/description/author,
14
- * `src/components/header.js` for the wordmark, `gatsby-plugin-manifest` for
15
- * "Anth.us AI Solutions", and `src/components/seo.js` for the
16
- * `"<page> | Anthus"` title pattern.
17
- */
18
- export const anthUsBrand: SiteBrand = {
19
- id: "anth-us",
20
- appTitle: "Anthus",
21
- appDescription:
22
- "Anthus builds and operates self-aligning AI systems — custom models, agent harnesses, and evaluation loops with a human in the loop, grounded in 14 years of production operations.",
23
- // The live header renders the bare word "Anthus" (Jersey 25, weight 900),
24
- // not a dotted domain wordmark, so this is not "ANTH.US".
25
- mastheadTitle: "Anthus",
26
- mastheadSubtitle: "AI Solutions",
27
- backToHomeLabel: "Back to Anthus",
28
- articleTitleSuffix: "Anthus",
29
- placeholderByline: "Ryan Porter",
30
- // The source site is a blog with a long single-column measure, not a
31
- // newsprint grid.
32
- defaultPresentation: "blog",
33
- textFont:
34
- '"Montserrat", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
35
- mastheadWordSplit: false,
36
- mastheadDateFormat: "formatted",
37
- mastheadSource: "brand",
38
- sectionLinkStrategy: "route",
39
- themePack: "anth-us",
40
- themeTokens: ANTH_US_THEME_PACK_TOKENS,
41
- renderer: { kind: "markus" },
42
- hosting: { kind: "amplify-ssr" },
43
- readerDeployment: {
44
- kind: "amplify-static",
45
- repository: "https://github.com/AnthusAI/Anth.us-Papyrus",
46
- // `domain` and `amplifyAppId` are deliberately unset.
47
- //
48
- // This reader is a **staging** port of a live marketing site. Production
49
- // `anth.us` stays pointed at the existing Gatsby build until the port has
50
- // been reviewed, so writing `domain: "anth.us"` here would assert a
51
- // canonical host this build does not serve. Staging runs on whatever
52
- // hostname Amplify hands out. No DNS or domain association is configured
53
- // anywhere in this repo for it.
54
- //
55
- // The repository is private while the port is half-finished; the URL above
56
- // will 404 for anyone without access. That is expected, not a typo.
57
- },
58
- rootRoute: { kind: "newsroom" },
59
- newsroomBasePath: "",
60
- opsChrome: "app",
61
- corpusKey: "anth-us",
62
- // Declared paths. The YAML files themselves are not in the repo yet — see
63
- // README.md "Not wired yet".
64
- steeringConfigPath: "corpora/anth-us-steering.yml",
65
- newsroomSectionsConfigPath: "corpora/anth-us-newsroom-sections.yml",
66
- analysisProfilesPath: "corpora/anth-us-analysis-profiles.yml",
67
- publicationName: "Anthus",
68
- };