@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
@@ -1,155 +1,181 @@
1
- export type AmplifyAppShellSiteConfig = {
2
- /** CDK context selector and stack id suffix. */
3
- siteId: string;
4
- /** Amplify app name (also the CloudFormation stack-friendly id). */
5
- appName: string;
6
- /** Human description shown in the Amplify console. */
7
- description: string;
8
- /** GitHub repo URL the CMS deploys from. */
9
- repository: string;
10
- /** Amplify platform. Papyrus CMS is always WEB_COMPUTE (Next.js SSR). */
11
- platform: "WEB_COMPUTE";
12
- /** Deploy branch. */
13
- branchName: string;
14
- /** Branch environment variables (PAPYRUS_SITE_BRAND etc.). */
15
- environment: Record<string, string>;
16
- /**
17
- * Secrets Manager secret name holding the GitHub access token used by
18
- * Amplify to clone the repo. CDK reads it via a dynamic reference so the
19
- * token never lands in the CloudFormation template. Populate the secret
20
- * before deploying. Replace the initial token with a dedicated fine-grained
21
- * PAT scoped to this repo.
22
- */
23
- githubTokenSecretName: string;
24
- /** Custom domain for the CMS (newsroom.<site>.us). Apex stays on the reader. */
25
- domainName: string;
26
- /** Route 53 hosted zone id for the domain. */
27
- hostedZoneId: string;
28
- /** Build compute type. */
29
- buildComputeType?: "STANDARD" | "STANDARD_8GB";
30
- /** Enable branch auto-build so pushes trigger deploys. */
31
- enableAutoBuild?: boolean;
32
- };
33
-
34
1
  import { Construct } from "constructs";
35
- import { Stack, StackProps, SecretValue } from "aws-cdk-lib";
2
+ import { ArnFormat, CfnOutput, Stack, StackProps } from "aws-cdk-lib";
36
3
  import * as iam from "aws-cdk-lib/aws-iam";
37
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
+ import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
6
+ import { AmplifyAppShellSiteConfig, resolveStagingDomainName } from "./site-config";
38
7
 
8
+ function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
9
+ return Object.entries(variables).map(([name, value]) => ({ name, value }));
10
+ }
39
11
 
40
12
  /**
41
- * Provisions the Amplify *app shell* for a Papyrus publication CMS:
42
- *
43
- * - AWS::Amplify::App (WEB_COMPUTE, GitHub repo connection via PAT in Secrets Manager)
44
- * - AWS::Amplify::Branch (main, with PAPYRUS_SITE_BRAND env vars)
45
- * - AWS::Amplify::Domain (newsroom.<site>.us on the site's Route 53 zone)
46
- * - IAM service role (backend deploy) + compute role (SSR rendering)
13
+ * Provisions the Amplify *app shell* of a Papyrus publication from its
14
+ * site.json:
47
15
  *
48
- * The Papyrus *backend* (AppSync, Storage, Lambda) is NOT created here. It
49
- * deploys via `ampx pipeline-deploy` from the repo `amplify.yml` once Amplify
50
- * CI runs a build on the branch. This stack only creates the container the
51
- * backend deploys into, so the app is never "created manually" in the console.
16
+ * - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
17
+ * and `staging` (frontend only, reads the production backend)
18
+ * - for `markus-static` sites, a reader app (WEB) with branch `main`
19
+ * - one domain association per branch/app, IAM service and compute roles
52
20
  *
53
- * The GitHub access token is read from Secrets Manager by dynamic reference,
54
- * so the token value never appears in the synthesized template.
21
+ * No repository and no access token are configured: the apps are manual-deploy
22
+ * until Amplify's GitHub App is connected once in the console
23
+ * (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
24
+ * app by `ampx pipeline-deploy` from the production build.
55
25
  */
56
26
  export class AmplifyAppShellStack extends Stack {
57
- public readonly appId: string;
58
- public readonly defaultDomain: string;
27
+ public readonly cmsAppId: string;
28
+ public readonly readerAppId?: string;
59
29
 
60
30
  constructor(scope: Construct, id: string, config: AmplifyAppShellSiteConfig, props?: StackProps) {
61
31
  super(scope, id, props);
62
32
 
63
- const githubToken = SecretValue.secretsManager(config.githubTokenSecretName).unsafeUnwrap();
33
+ const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
34
+ const stagingDomainName = resolveStagingDomainName(config);
64
35
 
65
- // Service role Amplify assumes to deploy the backend (ampx pipeline-deploy
66
- // creates AppSync, Storage, Lambda, etc., so it needs broad permissions).
67
- const serviceRole = new iam.Role(this, "AmplifyServiceRole", {
36
+ const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
68
37
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
69
- description: `Backend deploy service role for Amplify app ${config.appName}`,
38
+ description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
70
39
  managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")],
71
40
  });
72
41
 
73
- // WEB_COMPUTE (Next.js SSR) requires a compute role for the rendering server.
74
42
  const computeRole = new iam.Role(this, "AmplifyComputeRole", {
75
43
  assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
76
- description: `SSR compute role for Amplify app ${config.appName}`,
44
+ description: `SSR compute role for Amplify app ${cmsAppName}`,
77
45
  managedPolicies: [
78
46
  iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
79
47
  iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
80
48
  ],
81
49
  });
82
50
 
83
- const app = new amplify.CfnApp(this, "App", {
84
- name: config.appName,
85
- description: config.description,
86
- platform: config.platform,
87
- repository: config.repository,
88
- accessToken: githubToken,
89
- iamServiceRole: serviceRole.roleArn,
51
+ const cmsApp = new amplify.CfnApp(this, "App", {
52
+ name: cmsAppName,
53
+ description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
54
+ platform: "WEB_COMPUTE",
55
+ iamServiceRole: cmsServiceRole.roleArn,
90
56
  computeRoleArn: computeRole.roleArn,
91
- buildSpec: this.buildSpec(),
57
+ buildSpec: cmsProductionBuildSpec(config),
58
+ jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
92
59
  customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
93
60
  });
94
61
 
95
- this.appId = app.attrAppId;
96
- this.defaultDomain = `${app.attrAppId}.amplifyapp.com`;
62
+ const stagingMode: Record<string, string> = config.frontend === "markus-static"
63
+ ? { PAPYRUS_STAGING_PREVIEW: "static" }
64
+ : { PAPYRUS_CONTENT_SOURCE: "drafts" };
97
65
 
98
- const branch = new amplify.CfnBranch(this, "Branch", {
99
- appId: app.attrAppId,
100
- branchName: config.branchName,
66
+ const productionBranch = new amplify.CfnBranch(this, "Branch", {
67
+ appId: cmsApp.attrAppId,
68
+ branchName: "main",
101
69
  stage: "PRODUCTION",
102
- enableAutoBuild: config.enableAutoBuild ?? true,
70
+ enableAutoBuild: true,
71
+ enablePerformanceMode: false,
72
+ environmentVariables: environmentVariables({
73
+ ...config.cms.environment,
74
+ PAPYRUS_SITE_BRAND: config.brand,
75
+ SITE_ENV: "production",
76
+ PAPYRUS_CONTENT_SOURCE: "published",
77
+ }),
78
+ });
79
+
80
+ const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
81
+ appId: cmsApp.attrAppId,
82
+ branchName: "staging",
83
+ stage: "BETA",
84
+ enableAutoBuild: true,
103
85
  enablePerformanceMode: false,
104
- environmentVariables: Object.entries(config.environment).map(([key, value]) => ({
105
- name: key,
106
- value,
107
- })),
86
+ buildSpec: cmsStagingBuildSpec(config),
87
+ environmentVariables: environmentVariables({
88
+ ...config.cms.environment,
89
+ PAPYRUS_SITE_BRAND: config.brand,
90
+ SITE_ENV: "staging",
91
+ ...stagingMode,
92
+ NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
93
+ }),
94
+ });
95
+
96
+ const productionDomain = new amplify.CfnDomain(this, "Domain", {
97
+ appId: cmsApp.attrAppId,
98
+ domainName: config.cms.domainName,
99
+ subDomainSettings: [{ branchName: "main", prefix: "" }],
108
100
  });
109
- branch.addResourceDependency(app);
101
+ productionDomain.addResourceDependency(productionBranch);
110
102
 
111
- const domain = new amplify.CfnDomain(this, "Domain", {
112
- appId: app.attrAppId,
113
- domainName: config.domainName,
114
- subDomainSettings: [{ branchName: config.branchName, prefix: "" }],
103
+ const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
104
+ appId: cmsApp.attrAppId,
105
+ domainName: stagingDomainName,
106
+ subDomainSettings: [{ branchName: "staging", prefix: "" }],
115
107
  });
116
- domain.addResourceDependency(branch);
108
+ stagingDomain.addResourceDependency(stagingBranch);
117
109
 
118
- this.exportValue(app.attrAppId, { description: `Amplify app id for ${config.appName}` });
110
+ this.cmsAppId = cmsApp.attrAppId;
111
+ new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
112
+ new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
113
+ new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
114
+
115
+ if (config.reader) {
116
+ this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
117
+ }
119
118
  }
120
119
 
121
- /**
122
- * Build spec mirrors the repo root amplify.yml so the app deploys the full
123
- * Papyrus backend + Next.js frontend on every build. Inlined because Amplify
124
- * needs a buildSpec on the app for the first deploy before the repo is cloned.
125
- */
126
- private buildSpec(): string {
127
- return [
128
- "version: 1",
129
- "backend:",
130
- " phases:",
131
- " build:",
132
- " commands:",
133
- " - npm install --cache .npm --prefer-offline",
134
- " - |",
135
- " if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then",
136
- " export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true",
137
- " fi",
138
- " - npx ampx pipeline-deploy --debug --branch $AWS_BRANCH --app-id $AWS_APP_ID",
139
- "frontend:",
140
- " phases:",
141
- " build:",
142
- " commands:",
143
- " - env | grep -e '^PAPYRUS_CONTENT_SOURCE=' -e '^PAPYRUS_EDITION_SLUG=' -e '^PAPYRUS_REVALIDATE_SECRET=' >> .env.production",
144
- " - npm run build",
145
- " artifacts:",
146
- " baseDirectory: .next",
147
- " files:",
148
- " - '**/*'",
149
- " cache:",
150
- " paths:",
151
- " - .next/cache/**/*",
152
- " - .npm/**/*",
153
- ].join("\n");
120
+ private addReaderApp(
121
+ config: AmplifyAppShellSiteConfig,
122
+ reader: NonNullable<AmplifyAppShellSiteConfig["reader"]>,
123
+ cmsAppId: string,
124
+ ): string {
125
+ const readerAppName = reader.appName ?? `${config.siteId}-reader`;
126
+ const readerBranchName = reader.branchName ?? "main";
127
+
128
+ const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
129
+ assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
130
+ description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
131
+ });
132
+ readerServiceRole.addToPolicy(
133
+ new iam.PolicyStatement({
134
+ sid: "ReadSiteSecretsForJwtMinting",
135
+ actions: ["ssm:GetParameter", "ssm:GetParameters"],
136
+ resources: [
137
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
138
+ this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
139
+ ],
140
+ }),
141
+ );
142
+ readerServiceRole.addToPolicy(
143
+ new iam.PolicyStatement({
144
+ sid: "ReadMediaForExport",
145
+ actions: ["s3:GetObject"],
146
+ resources: ["arn:aws:s3:::amplify-*/media/*"],
147
+ }),
148
+ );
149
+
150
+ const readerApp = new amplify.CfnApp(this, "ReaderApp", {
151
+ name: readerAppName,
152
+ description: `Papyrus static reader for ${config.siteId} (WEB)`,
153
+ platform: "WEB",
154
+ iamServiceRole: readerServiceRole.roleArn,
155
+ buildSpec: readerBuildSpec(config),
156
+ });
157
+
158
+ const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
159
+ appId: readerApp.attrAppId,
160
+ branchName: readerBranchName,
161
+ stage: "PRODUCTION",
162
+ enableAutoBuild: true,
163
+ environmentVariables: environmentVariables({
164
+ ...(reader.environment ?? {}),
165
+ PAPYRUS_SITE_BRAND: config.brand,
166
+ SITE_ENV: "production",
167
+ }),
168
+ });
169
+
170
+ const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
171
+ appId: readerApp.attrAppId,
172
+ domainName: reader.domainName,
173
+ subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
174
+ });
175
+ readerDomain.addResourceDependency(readerBranch);
176
+
177
+ new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
178
+ new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
179
+ return readerApp.attrAppId;
154
180
  }
155
181
  }
@@ -0,0 +1,86 @@
1
+ import { resolveStoragePreviewPrefix } from "./site-config.js";
2
+ export function papyrusVersionToPep440(version) {
3
+ const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+\.(\d+))?$/.exec(version);
4
+ if (!match)
5
+ throw new Error(`cannot map ${version} to PEP 440`);
6
+ return match[2] === undefined ? match[1] : `${match[1]}.dev${match[2]}`;
7
+ }
8
+ function yamlScalar(command) {
9
+ const needsQuoting = /: | #|^[-?:,\[\]{}#&*!|>'"%@`]|:$/.test(command);
10
+ return needsQuoting ? `'${command.replace(/'/g, "''")}'` : command;
11
+ }
12
+ function commandLines(commands, indent) {
13
+ return commands.map((command) => `${indent}- ${yamlScalar(command)}`);
14
+ }
15
+ const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
16
+ const FRONTEND_ENVIRONMENT_CAPTURE = "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
17
+ function pythonProvisioningCommands(config) {
18
+ return [
19
+ "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
20
+ "curl -LsSf https://astral.sh/uv/install.sh | sh",
21
+ "export PATH=\"$HOME/.local/bin:$PATH\"",
22
+ "uv python install 3.12",
23
+ "uv venv --python 3.12 .venv",
24
+ `uv pip install --python .venv "papyrus-newsroom[markus]==${papyrusVersionToPep440(config.papyrusVersion)}"`,
25
+ "export PATH=\"$PWD/.venv/bin:$PATH\"",
26
+ ];
27
+ }
28
+ function reader(config) {
29
+ if (!config.reader)
30
+ throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
31
+ return config.reader;
32
+ }
33
+ function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, cachePaths) {
34
+ const lines = ["frontend:", " phases:"];
35
+ if (preBuildCommands.length > 0) {
36
+ lines.push(" preBuild:", " commands:", ...commandLines(preBuildCommands, " "));
37
+ }
38
+ lines.push(" build:", " commands:", ...commandLines(buildCommands, " "), " artifacts:", ` baseDirectory: ${artifactsDirectory}`, " files:", " - '**/*'", " cache:", " paths:", ...cachePaths.map((cachePath) => ` - ${cachePath}`));
39
+ return lines;
40
+ }
41
+ export function cmsProductionBuildSpec(config) {
42
+ return [
43
+ "version: 1",
44
+ "backend:",
45
+ " phases:",
46
+ " build:",
47
+ " commands:",
48
+ ...commandLines([
49
+ NPM_CLEAN_INSTALL,
50
+ "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
51
+ "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
52
+ ], " "),
53
+ ...frontendSection([], [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", [".next/cache/**/*", ".npm/**/*"]),
54
+ ].join("\n") + "\n";
55
+ }
56
+ export function cmsStagingBuildSpec(config) {
57
+ const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
58
+ const cachePaths = [".next/cache/**/*", ".npm/**/*"];
59
+ if (config.frontend === "markus-static") {
60
+ const readerConfig = reader(config);
61
+ preBuild.push(...pythonProvisioningCommands(config), "papyrus auth refresh-jwt --write-env .env", "papyrus content export-published --drafts --out content-export --clean", readerConfig.buildCommand, `papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
62
+ cachePaths.push(".uv-cache/**/*");
63
+ }
64
+ return [
65
+ "version: 1",
66
+ ...frontendSection(preBuild, [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"], ".next", cachePaths),
67
+ ].join("\n") + "\n";
68
+ }
69
+ export function readerBuildSpec(config) {
70
+ const readerConfig = reader(config);
71
+ return [
72
+ "version: 1",
73
+ ...frontendSection([
74
+ ...pythonProvisioningCommands(config),
75
+ "papyrus auth refresh-jwt --write-env .env",
76
+ "papyrus content export-published --out content-export --clean",
77
+ ], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
78
+ ].join("\n") + "\n";
79
+ }
80
+ export function buildSpecFor(config, app) {
81
+ if (app === "cms-production")
82
+ return cmsProductionBuildSpec(config);
83
+ if (app === "cms-staging")
84
+ return cmsStagingBuildSpec(config);
85
+ return readerBuildSpec(config);
86
+ }
@@ -0,0 +1,132 @@
1
+ import { AmplifyAppShellSiteConfig, resolveStoragePreviewPrefix } from "./site-config";
2
+
3
+ export type BuildSpecAppName = "cms-production" | "cms-staging" | "reader";
4
+
5
+ export function papyrusVersionToPep440(version: string): string {
6
+ const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z-]+\.(\d+))?$/.exec(version);
7
+ if (!match) throw new Error(`cannot map ${version} to PEP 440`);
8
+ return match[2] === undefined ? match[1] : `${match[1]}.dev${match[2]}`;
9
+ }
10
+
11
+ function yamlScalar(command: string): string {
12
+ const needsQuoting = /: | #|^[-?:,\[\]{}#&*!|>'"%@`]|:$/.test(command);
13
+ return needsQuoting ? `'${command.replace(/'/g, "''")}'` : command;
14
+ }
15
+
16
+ function commandLines(commands: string[], indent: string): string[] {
17
+ return commands.map((command) => `${indent}- ${yamlScalar(command)}`);
18
+ }
19
+
20
+ const NPM_CLEAN_INSTALL = "npm ci --cache .npm --prefer-offline";
21
+
22
+ const FRONTEND_ENVIRONMENT_CAPTURE =
23
+ "env | grep -e '^PAPYRUS_' -e '^SITE_ENV=' -e '^NEXT_PUBLIC_' >> .env.production";
24
+
25
+ function pythonProvisioningCommands(config: AmplifyAppShellSiteConfig): string[] {
26
+ return [
27
+ "export UV_CACHE_DIR=\"$PWD/.uv-cache\"",
28
+ "curl -LsSf https://astral.sh/uv/install.sh | sh",
29
+ "export PATH=\"$HOME/.local/bin:$PATH\"",
30
+ "uv python install 3.12",
31
+ "uv venv --python 3.12 .venv",
32
+ `uv pip install --python .venv "papyrus-newsroom[markus]==${papyrusVersionToPep440(config.papyrusVersion)}"`,
33
+ "export PATH=\"$PWD/.venv/bin:$PATH\"",
34
+ ];
35
+ }
36
+
37
+ function reader(config: AmplifyAppShellSiteConfig): NonNullable<AmplifyAppShellSiteConfig["reader"]> {
38
+ if (!config.reader) throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
39
+ return config.reader;
40
+ }
41
+
42
+ function frontendSection(preBuildCommands: string[], buildCommands: string[], artifactsDirectory: string, cachePaths: string[]): string[] {
43
+ const lines = ["frontend:", " phases:"];
44
+ if (preBuildCommands.length > 0) {
45
+ lines.push(" preBuild:", " commands:", ...commandLines(preBuildCommands, " "));
46
+ }
47
+ lines.push(
48
+ " build:",
49
+ " commands:",
50
+ ...commandLines(buildCommands, " "),
51
+ " artifacts:",
52
+ ` baseDirectory: ${artifactsDirectory}`,
53
+ " files:",
54
+ " - '**/*'",
55
+ " cache:",
56
+ " paths:",
57
+ ...cachePaths.map((cachePath) => ` - ${cachePath}`),
58
+ );
59
+ return lines;
60
+ }
61
+
62
+ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): string {
63
+ return [
64
+ "version: 1",
65
+ "backend:",
66
+ " phases:",
67
+ " build:",
68
+ " commands:",
69
+ ...commandLines(
70
+ [
71
+ NPM_CLEAN_INSTALL,
72
+ "if [ -z \"${PAPYRUS_CONSOLE_RESPONDER_IMAGE_URI:-}\" ]; then export PAPYRUS_CONSOLE_RESPONDER_ALLOW_LOCAL_BUILD=true; fi",
73
+ "npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID",
74
+ ],
75
+ " ",
76
+ ),
77
+ ...frontendSection(
78
+ [],
79
+ [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
80
+ ".next",
81
+ [".next/cache/**/*", ".npm/**/*"],
82
+ ),
83
+ ].join("\n") + "\n";
84
+ }
85
+
86
+ export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
87
+ const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
88
+ const cachePaths = [".next/cache/**/*", ".npm/**/*"];
89
+ if (config.frontend === "markus-static") {
90
+ const readerConfig = reader(config);
91
+ preBuild.push(
92
+ ...pythonProvisioningCommands(config),
93
+ "papyrus auth refresh-jwt --write-env .env",
94
+ "papyrus content export-published --drafts --out content-export --clean",
95
+ readerConfig.buildCommand,
96
+ `papyrus content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
97
+ );
98
+ cachePaths.push(".uv-cache/**/*");
99
+ }
100
+ return [
101
+ "version: 1",
102
+ ...frontendSection(
103
+ preBuild,
104
+ [FRONTEND_ENVIRONMENT_CAPTURE, "npx papyrus-app sync && npm run build"],
105
+ ".next",
106
+ cachePaths,
107
+ ),
108
+ ].join("\n") + "\n";
109
+ }
110
+
111
+ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
112
+ const readerConfig = reader(config);
113
+ return [
114
+ "version: 1",
115
+ ...frontendSection(
116
+ [
117
+ ...pythonProvisioningCommands(config),
118
+ "papyrus auth refresh-jwt --write-env .env",
119
+ "papyrus content export-published --out content-export --clean",
120
+ ],
121
+ [readerConfig.buildCommand],
122
+ readerConfig.baseDirectory,
123
+ [".uv-cache/**/*"],
124
+ ),
125
+ ].join("\n") + "\n";
126
+ }
127
+
128
+ export function buildSpecFor(config: AmplifyAppShellSiteConfig, app: BuildSpecAppName): string {
129
+ if (app === "cms-production") return cmsProductionBuildSpec(config);
130
+ if (app === "cms-staging") return cmsStagingBuildSpec(config);
131
+ return readerBuildSpec(config);
132
+ }
package/infra/index.js CHANGED
@@ -10,3 +10,5 @@ try {
10
10
  );
11
11
  }
12
12
  export const { AmplifyAppShellStack } = await import("./amplify-app-shell.js");
13
+ export { parseSiteConfig } from "./site-config.js";
14
+ export { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec, buildSpecFor } from "./build-specs.js";
@@ -0,0 +1,125 @@
1
+ const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2
+ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3
+ const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
4
+ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
5
+ const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
6
+ const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
7
+ const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
8
+ const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
9
+ function fail(field, problem) {
10
+ throw new Error(`site.json: ${field} ${problem}`);
11
+ }
12
+ function requireObject(value, field) {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ fail(field, "must be an object");
15
+ }
16
+ return value;
17
+ }
18
+ function requireString(value, field, pattern, patternHint) {
19
+ if (typeof value !== "string" || value.trim() === "") {
20
+ fail(field, "must be a non-empty string");
21
+ }
22
+ if (pattern && !pattern.test(value)) {
23
+ fail(field, `is invalid${patternHint ? ` (${patternHint})` : ""}: ${JSON.stringify(value)}`);
24
+ }
25
+ return value;
26
+ }
27
+ function optionalString(value, field, pattern, patternHint) {
28
+ return value === undefined ? undefined : requireString(value, field, pattern, patternHint);
29
+ }
30
+ function requireStringRecord(value, field) {
31
+ const record = requireObject(value, field);
32
+ const result = {};
33
+ for (const [key, entry] of Object.entries(record)) {
34
+ if (typeof entry !== "string")
35
+ fail(`${field}.${key}`, "must be a string");
36
+ result[key] = entry;
37
+ }
38
+ return result;
39
+ }
40
+ function rejectUnknownKeys(record, allowed, field) {
41
+ for (const key of Object.keys(record)) {
42
+ if (!allowed.includes(key))
43
+ fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
44
+ }
45
+ }
46
+ export function resolveStagingDomainName(config) {
47
+ if (config.cms.stagingDomainName)
48
+ return config.cms.stagingDomainName;
49
+ const [, ...rest] = config.cms.domainName.split(".");
50
+ return ["staging", ...rest].join(".");
51
+ }
52
+ export function resolveStoragePreviewPrefix(config) {
53
+ return config.storagePreviewPrefix ?? "preview/";
54
+ }
55
+ export function parseSiteConfig(raw) {
56
+ const record = requireObject(raw, "site");
57
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"], "site");
58
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
59
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
60
+ const brand = requireString(record.brand, "brand");
61
+ const frontend = requireString(record.frontend, "frontend");
62
+ if (frontend !== "pretext" && frontend !== "markus-static") {
63
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
64
+ }
65
+ const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
66
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
67
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
68
+ const cmsRecord = requireObject(record.cms, "cms");
69
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
70
+ const buildComputeType = cmsRecord.buildComputeType;
71
+ if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
72
+ fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
73
+ }
74
+ const cms = {
75
+ appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
76
+ domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
77
+ buildComputeType: buildComputeType,
78
+ environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
79
+ stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
80
+ };
81
+ let reader;
82
+ if (record.reader !== undefined) {
83
+ if (frontend !== "markus-static")
84
+ fail("reader", `is only allowed when frontend is "markus-static"`);
85
+ const readerRecord = requireObject(record.reader, "reader");
86
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
87
+ reader = {
88
+ appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
89
+ domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
90
+ branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
91
+ buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
92
+ baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
93
+ environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
94
+ };
95
+ }
96
+ else if (frontend === "markus-static") {
97
+ fail("reader", `is required when frontend is "markus-static"`);
98
+ }
99
+ const config = {
100
+ siteId,
101
+ repository,
102
+ brand,
103
+ frontend,
104
+ hostedZoneId,
105
+ cms,
106
+ reader,
107
+ papyrusVersion,
108
+ storagePreviewPrefix,
109
+ };
110
+ const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
111
+ if (redirectValue === undefined)
112
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
113
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
114
+ const requiredOrigins = [
115
+ `https://${cms.domainName}/`,
116
+ `https://${resolveStagingDomainName(config)}/`,
117
+ LOCAL_DEVELOPMENT_ORIGIN,
118
+ ];
119
+ for (const origin of requiredOrigins) {
120
+ if (!redirectUrls.includes(origin)) {
121
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
122
+ }
123
+ }
124
+ return config;
125
+ }