@anthusai/papyrus 1.0.0-next.48 → 1.0.0-next.49

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.
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from papyrus_content.edge_invalidation import distribution_ids_from_environment, invalidate_revalidated_paths
8
8
  from papyrus_content.graphql_authoring import create_authoring_client
9
9
  from papyrus_content.markus_renderer.derive import derive_body
10
- from papyrus_content.projection.engine import project, project_item_pages
10
+ from papyrus_content.projection.engine import project, project_all, project_item_pages
11
11
  from papyrus_content.projection.runtime import (
12
12
  DEAD_LETTER_QUEUE_ENV,
13
13
  context_from_environment,
@@ -155,6 +155,10 @@ def run_projection_task(task: dict) -> dict:
155
155
  report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
156
156
  if report.follow_up_item_pages:
157
157
  schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
158
+ elif task.get("task") == "all":
159
+ report = project_all(projection_context(client))
160
+ elif task.get("task") == "all":
161
+ report = project_all(projection_context(client))
158
162
  elif task.get("task") == "invalidate":
159
163
  invalidate = projection_context(client).invalidate
160
164
  if invalidate is None:
@@ -0,0 +1,18 @@
1
+ export const DEFAULT_MARKUS_PUBLICATION = { kind: "s3-projection", reprojectOnDeploy: true };
2
+ /**
3
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
4
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
5
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
6
+ * they write a `publication`.
7
+ */
8
+ export function resolveSitePublication(backend, rendererKind) {
9
+ if (backend.publication === false)
10
+ return undefined;
11
+ if (backend.publication !== undefined)
12
+ return backend.publication;
13
+ if (rendererKind !== "markus")
14
+ return undefined;
15
+ if (backend.reader !== undefined)
16
+ return undefined;
17
+ return DEFAULT_MARKUS_PUBLICATION;
18
+ }
@@ -0,0 +1,20 @@
1
+ import type { PapyrusPublicationConfig, PapyrusSiteBackendConfig } from "../site-backend-config";
2
+
3
+ export const DEFAULT_MARKUS_PUBLICATION: PapyrusPublicationConfig = { kind: "s3-projection", reprojectOnDeploy: true };
4
+
5
+ /**
6
+ * The publication a site backend is built with. A Markus site that names a `reader` Amplify app
7
+ * keeps publishing through it (`amplify-static`); every other Markus site gets the s3-projection
8
+ * default unless it writes `publication: false`. Sites with another renderer get nothing unless
9
+ * they write a `publication`.
10
+ */
11
+ export function resolveSitePublication(
12
+ backend: Pick<PapyrusSiteBackendConfig, "publication" | "reader">,
13
+ rendererKind: string | undefined,
14
+ ): PapyrusPublicationConfig | undefined {
15
+ if (backend.publication === false) return undefined;
16
+ if (backend.publication !== undefined) return backend.publication;
17
+ if (rendererKind !== "markus") return undefined;
18
+ if (backend.reader !== undefined) return undefined;
19
+ return DEFAULT_MARKUS_PUBLICATION;
20
+ }
@@ -0,0 +1,31 @@
1
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
2
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
3
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
4
+ export function reprojectDeployToken(environment, now = Date.now) {
5
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
6
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
7
+ }
8
+ /**
9
+ * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
10
+ * The token is in the request, so CloudFormation sees a changed property on each deploy.
11
+ */
12
+ export function addReprojectOnDeploy(stack, contentActions, deployToken) {
13
+ const invocation = {
14
+ service: "Lambda",
15
+ action: "invoke",
16
+ parameters: {
17
+ FunctionName: contentActions.functionName,
18
+ InvocationType: "Event",
19
+ Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
20
+ },
21
+ physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
22
+ };
23
+ return new AwsCustomResource(stack, "ReprojectOnDeploy", {
24
+ onCreate: invocation,
25
+ onUpdate: invocation,
26
+ installLatestAwsSdk: false,
27
+ policy: AwsCustomResourcePolicy.fromStatements([
28
+ new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
29
+ ]),
30
+ });
31
+ }
@@ -0,0 +1,36 @@
1
+ import { Stack } from "aws-cdk-lib";
2
+ import { PolicyStatement } from "aws-cdk-lib/aws-iam";
3
+ import type { IFunction } from "aws-cdk-lib/aws-lambda";
4
+ import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
5
+
6
+ export const REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS = 300;
7
+
8
+ export function reprojectDeployToken(environment: Record<string, string | undefined>, now: () => number = Date.now): string {
9
+ const buildJobId = (environment.AWS_JOB_ID ?? "").trim();
10
+ return buildJobId !== "" ? `job-${buildJobId}` : `time-${now()}`;
11
+ }
12
+
13
+ /**
14
+ * Starts the content-actions `all` projection task (asynchronously) every time the stack deploys.
15
+ * The token is in the request, so CloudFormation sees a changed property on each deploy.
16
+ */
17
+ export function addReprojectOnDeploy(stack: Stack, contentActions: IFunction, deployToken: string): AwsCustomResource {
18
+ const invocation = {
19
+ service: "Lambda",
20
+ action: "invoke",
21
+ parameters: {
22
+ FunctionName: contentActions.functionName,
23
+ InvocationType: "Event",
24
+ Payload: JSON.stringify({ projectionTask: { task: "all", deployToken } }),
25
+ },
26
+ physicalResourceId: PhysicalResourceId.of(`reproject-${deployToken}`),
27
+ };
28
+ return new AwsCustomResource(stack, "ReprojectOnDeploy", {
29
+ onCreate: invocation,
30
+ onUpdate: invocation,
31
+ installLatestAwsSdk: false,
32
+ policy: AwsCustomResourcePolicy.fromStatements([
33
+ new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [contentActions.functionArn] }),
34
+ ]),
35
+ });
36
+ }
@@ -27,6 +27,9 @@ export function validatePublicationConfig(publication) {
27
27
  if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
28
28
  problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
29
29
  }
30
+ if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
31
+ problems.push("publication.reprojectOnDeploy must be a boolean");
32
+ }
30
33
  const cache = config.cache;
31
34
  if (cache !== undefined) {
32
35
  const lifetime = cache.htmlSMaxAgeSeconds;
@@ -52,6 +52,12 @@ export type PapyrusPublicationConfig = {
52
52
  distribution?: S3ProjectionDistributionConfig;
53
53
  /** Default true: the site bucket is retained when the stack is deleted. `false` destroys it and its objects. */
54
54
  retainBucketOnDelete?: boolean;
55
+ /**
56
+ * Re-project every published page after each backend deploy, so a theme or code change reaches the
57
+ * site without a manual `papyrus ops content project --all`. Default: true for the publication a
58
+ * Markus site gets when it sets none, false when `publication` is written out in the site config.
59
+ */
60
+ reprojectOnDeploy?: boolean;
55
61
  };
56
62
 
57
63
  const CLOUDFRONT_DISTRIBUTION_ARN = /^arn:aws[a-z-]*:cloudfront::\d{12}:distribution\/[A-Z0-9]+$/;
@@ -83,6 +89,9 @@ export function validatePublicationConfig(publication: unknown): string[] {
83
89
  if (config.edgeDistributionArn !== undefined && !CLOUDFRONT_DISTRIBUTION_ARN.test(config.edgeDistributionArn)) {
84
90
  problems.push("publication.edgeDistributionArn must be a CloudFront distribution ARN (arn:aws:cloudfront::<account>:distribution/<id>)");
85
91
  }
92
+ if (config.reprojectOnDeploy !== undefined && typeof config.reprojectOnDeploy !== "boolean") {
93
+ problems.push("publication.reprojectOnDeploy must be a boolean");
94
+ }
86
95
  const cache = config.cache;
87
96
  if (cache !== undefined) {
88
97
  const lifetime = cache.htmlSMaxAgeSeconds;
@@ -156,8 +165,12 @@ export type PapyrusSiteBackendConfig = {
156
165
  /** Let this stack verify the SES domain identity and write its TXT record. Default false: verify once by hand. */
157
166
  manageDomainIdentity?: boolean;
158
167
  };
159
- /** Opt-in static projection of published pages into an S3 site bucket. Unset: nothing is created. */
160
- publication?: PapyrusPublicationConfig;
168
+ /**
169
+ * Static projection of published pages into an S3 site bucket. Unset: a Markus site without a
170
+ * `reader` gets `{ kind: "s3-projection" }`; any other site gets nothing. `false` opts a Markus
171
+ * site out. See `resolveSitePublication`.
172
+ */
173
+ publication?: PapyrusPublicationConfig | false;
161
174
  features?: {
162
175
  consoleResponder?: boolean;
163
176
  inboundEmail?: boolean;
@@ -7,6 +7,8 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
7
7
  import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
10
+ import { resolveSitePublication } from "./publication/default-publication.js";
11
+ import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy.js";
10
12
  import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
11
13
  import { planRenditions } from "./publication/renditions-settings.js";
12
14
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
@@ -68,10 +70,11 @@ export function defineSiteBackend(papyrusSite) {
68
70
  ...baseAuthConfig,
69
71
  newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
70
72
  };
71
- if (site.publication !== undefined)
72
- assertValidPublicationConfig(site.publication);
73
- const publicationPlan = planS3ProjectionBackend(site.publication);
74
- const renditionsPlan = planRenditions(site.publication, process.cwd());
73
+ const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
74
+ if (publication !== undefined)
75
+ assertValidPublicationConfig(publication);
76
+ const publicationPlan = planS3ProjectionBackend(publication);
77
+ const renditionsPlan = planRenditions(publication, process.cwd());
75
78
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
76
79
  const backend = defineBackend({
77
80
  assignmentAction,
@@ -314,12 +317,16 @@ export function defineSiteBackend(papyrusSite) {
314
317
  contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
315
318
  }
316
319
  let s3ProjectionPublication;
317
- if (site.publication !== undefined) {
318
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
320
+ if (publication !== undefined) {
321
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), publication, {
319
322
  mediaBucket: storageBucket,
320
323
  renditions: renditionsPlan,
321
324
  });
322
325
  contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
326
+ if (publication.reprojectOnDeploy === true) {
327
+ contentActionsLambda.node.defaultChild.timeout = REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS;
328
+ addReprojectOnDeploy(backend.createStack("reproject-on-deploy"), contentActionsLambda, reprojectDeployToken(process.env));
329
+ }
323
330
  }
324
331
  if (s3ProjectionPublication !== undefined) {
325
332
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
@@ -10,6 +10,8 @@ import type { Construct } from "constructs";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
13
+ import { resolveSitePublication } from "./publication/default-publication";
14
+ import { REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS, addReprojectOnDeploy, reprojectDeployToken } from "./publication/reproject-on-deploy";
13
15
  import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
14
16
  import { planRenditions } from "./publication/renditions-settings";
15
17
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
@@ -90,9 +92,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
90
92
  ...baseAuthConfig,
91
93
  newsroomBasePath: baseAuthConfig.newsroomBasePath ?? activeBrand?.newsroomBasePath,
92
94
  };
93
- if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
94
- const publicationPlan = planS3ProjectionBackend(site.publication);
95
- const renditionsPlan = planRenditions(site.publication, process.cwd());
95
+ const publication = resolveSitePublication(site, activeBrand?.renderer?.kind);
96
+ if (publication !== undefined) assertValidPublicationConfig(publication);
97
+ const publicationPlan = planS3ProjectionBackend(publication);
98
+ const renditionsPlan = planRenditions(publication, process.cwd());
96
99
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
97
100
  const backend = defineBackend({
98
101
  assignmentAction,
@@ -403,12 +406,16 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
403
406
  );
404
407
  }
405
408
  let s3ProjectionPublication: S3ProjectionPublication | undefined;
406
- if (site.publication !== undefined) {
407
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
409
+ if (publication !== undefined) {
410
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), publication, {
408
411
  mediaBucket: storageBucket,
409
412
  renditions: renditionsPlan,
410
413
  });
411
414
  (contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
415
+ if (publication.reprojectOnDeploy === true) {
416
+ (contentActionsLambda.node.defaultChild as CfnFunction).timeout = REPROJECT_ON_DEPLOY_TIMEOUT_SECONDS;
417
+ addReprojectOnDeploy(backend.createStack("reproject-on-deploy"), contentActionsLambda, reprojectDeployToken(process.env));
418
+ }
412
419
  }
413
420
  if (s3ProjectionPublication !== undefined) {
414
421
  addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
@@ -32,6 +32,9 @@ function reader(config) {
32
32
  throw new Error(`site ${config.siteId} has no reader app (frontend ${config.frontend})`);
33
33
  return config.reader;
34
34
  }
35
+ function stagingPreviewFor(config) {
36
+ return config.cms?.stagingPreview ?? config.reader;
37
+ }
35
38
  function frontendSection(preBuildCommands, buildCommands, artifactsDirectory, cachePaths) {
36
39
  const lines = ["frontend:", " phases:"];
37
40
  if (preBuildCommands.length > 0) {
@@ -62,9 +65,9 @@ export function cmsProductionBuildSpec(config) {
62
65
  export function cmsStagingBuildSpec(config) {
63
66
  const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
64
67
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
65
- if (config.frontend === "markus-static") {
66
- const readerConfig = reader(config);
67
- preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, readerConfig.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
68
+ const preview = config.frontend === "markus-static" ? stagingPreviewFor(config) : undefined;
69
+ if (preview !== undefined) {
70
+ preBuild.push(...pythonProvisioningCommands(config), `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`, preview.buildCommand, `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${preview.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`);
68
71
  cachePaths.push(".uv-cache/**/*");
69
72
  }
70
73
  return [
@@ -43,6 +43,10 @@ function reader(config: AmplifyAppShellSiteConfig): NonNullable<AmplifyAppShellS
43
43
  return config.reader;
44
44
  }
45
45
 
46
+ function stagingPreviewFor(config: AmplifyAppShellSiteConfig): { buildCommand: string; baseDirectory: string } | undefined {
47
+ return config.cms?.stagingPreview ?? config.reader;
48
+ }
49
+
46
50
  function frontendSection(preBuildCommands: string[], buildCommands: string[], artifactsDirectory: string, cachePaths: string[]): string[] {
47
51
  const lines = ["frontend:", " phases:"];
48
52
  if (preBuildCommands.length > 0) {
@@ -94,13 +98,13 @@ export function cmsProductionBuildSpec(config: AmplifyAppShellSiteConfig): strin
94
98
  export function cmsStagingBuildSpec(config: AmplifyAppShellSiteConfig): string {
95
99
  const preBuild = [NPM_CLEAN_INSTALL, "npx ampx generate outputs --branch main --app-id $AWS_APP_ID"];
96
100
  const cachePaths = [".next/cache/**/*", ".npm/**/*"];
97
- if (config.frontend === "markus-static") {
98
- const readerConfig = reader(config);
101
+ const preview = config.frontend === "markus-static" ? stagingPreviewFor(config) : undefined;
102
+ if (preview !== undefined) {
99
103
  preBuild.push(
100
104
  ...pythonProvisioningCommands(config),
101
105
  `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content export-published --drafts --out content-export --clean`,
102
- readerConfig.buildCommand,
103
- `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${readerConfig.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
106
+ preview.buildCommand,
107
+ `${CHECKOUT_AS_PAPYRUS_ROOT} papyrus ops content upload-preview --dir ${preview.baseDirectory} --prefix ${resolveStoragePreviewPrefix(config)}`,
104
108
  );
105
109
  cachePaths.push(".uv-cache/**/*");
106
110
  }
@@ -169,9 +169,19 @@ export function resolveStackName(config) {
169
169
  export function resolveStoragePreviewPrefix(config) {
170
170
  return config.storagePreviewPrefix ?? "preview/";
171
171
  }
172
+ function parseStagingPreview(value) {
173
+ if (value === undefined)
174
+ return undefined;
175
+ const record = requireObject(value, "cms.stagingPreview");
176
+ rejectUnknownKeys(record, ["buildCommand", "baseDirectory"], "cms.stagingPreview");
177
+ return {
178
+ buildCommand: requireString(record.buildCommand, "cms.stagingPreview.buildCommand"),
179
+ baseDirectory: requireString(record.baseDirectory, "cms.stagingPreview.baseDirectory"),
180
+ };
181
+ }
172
182
  function parseCms(value) {
173
183
  const cmsRecord = requireObject(value, "cms");
174
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
184
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
175
185
  const buildComputeType = cmsRecord.buildComputeType;
176
186
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
177
187
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -194,6 +204,7 @@ function parseCms(value) {
194
204
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
195
205
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
196
206
  redirects: parseRedirects(cmsRecord.redirects),
207
+ stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
197
208
  };
198
209
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
199
210
  if (reservedWord) {
@@ -306,8 +317,14 @@ export function parseSiteConfig(raw) {
306
317
  if (reader.domainPrefixes && !reader.domainName)
307
318
  fail("reader.domainPrefixes", "requires reader.domainName");
308
319
  }
309
- else if (frontend === "markus-static") {
310
- fail("reader", `is required when frontend is "markus-static"`);
320
+ else if (frontend === "markus-static" && cms === undefined) {
321
+ fail("reader", `is required when frontend is "markus-static" and there is no cms section`);
322
+ }
323
+ if (cms?.stagingPreview !== undefined && frontend !== "markus-static") {
324
+ fail("cms.stagingPreview", `is only allowed when frontend is "markus-static"`);
325
+ }
326
+ if (cms?.stagingPreview !== undefined && cms.staging === false) {
327
+ fail("cms.stagingPreview", "must not be set when cms.staging is false");
311
328
  }
312
329
  let github;
313
330
  if (record.github !== undefined) {
@@ -18,11 +18,22 @@ export type ReaderCustomHeaderRule = {
18
18
 
19
19
  export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
20
20
 
21
+ /**
22
+ * What the CMS `staging` branch builds to preview drafts of a markus-static site: the site build
23
+ * command and the directory it writes. Without it, and without a `reader` to borrow them from,
24
+ * the staging branch builds the newsroom only.
25
+ */
26
+ export type StagingPreviewConfig = {
27
+ buildCommand: string;
28
+ baseDirectory: string;
29
+ };
30
+
21
31
  export type CmsConfig = {
22
32
  appName?: string;
23
33
  domainName?: string;
24
34
  domainPrefix?: string;
25
35
  staging?: boolean;
36
+ stagingPreview?: StagingPreviewConfig;
26
37
  cognitoDomainPrefix: string;
27
38
  applyCognitoDomainPrefix?: boolean;
28
39
  buildComputeType?: "STANDARD" | "STANDARD_8GB";
@@ -236,9 +247,19 @@ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConf
236
247
  return config.storagePreviewPrefix ?? "preview/";
237
248
  }
238
249
 
250
+ function parseStagingPreview(value: unknown): StagingPreviewConfig | undefined {
251
+ if (value === undefined) return undefined;
252
+ const record = requireObject(value, "cms.stagingPreview");
253
+ rejectUnknownKeys(record, ["buildCommand", "baseDirectory"], "cms.stagingPreview");
254
+ return {
255
+ buildCommand: requireString(record.buildCommand, "cms.stagingPreview.buildCommand"),
256
+ baseDirectory: requireString(record.baseDirectory, "cms.stagingPreview.baseDirectory"),
257
+ };
258
+ }
259
+
239
260
  function parseCms(value: unknown): CmsConfig {
240
261
  const cmsRecord = requireObject(value, "cms");
241
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
262
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects", "stagingPreview"], "cms");
242
263
  const buildComputeType = cmsRecord.buildComputeType;
243
264
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
244
265
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
@@ -266,6 +287,7 @@ function parseCms(value: unknown): CmsConfig {
266
287
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
267
288
  stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
268
289
  redirects: parseRedirects(cmsRecord.redirects),
290
+ stagingPreview: parseStagingPreview(cmsRecord.stagingPreview),
269
291
  };
270
292
 
271
293
  const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
@@ -384,8 +406,14 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
384
406
  skipContentExport: readerRecord.skipContentExport as boolean | undefined,
385
407
  };
386
408
  if (reader.domainPrefixes && !reader.domainName) fail("reader.domainPrefixes", "requires reader.domainName");
387
- } else if (frontend === "markus-static") {
388
- fail("reader", `is required when frontend is "markus-static"`);
409
+ } else if (frontend === "markus-static" && cms === undefined) {
410
+ fail("reader", `is required when frontend is "markus-static" and there is no cms section`);
411
+ }
412
+ if (cms?.stagingPreview !== undefined && frontend !== "markus-static") {
413
+ fail("cms.stagingPreview", `is only allowed when frontend is "markus-static"`);
414
+ }
415
+ if (cms?.stagingPreview !== undefined && cms.staging === false) {
416
+ fail("cms.stagingPreview", "must not be set when cms.staging is false");
389
417
  }
390
418
 
391
419
  let github: AmplifyAppShellSiteConfig["github"];
package/package.json CHANGED
@@ -303,7 +303,7 @@
303
303
  "optional": true
304
304
  }
305
305
  },
306
- "version": "1.0.0-next.48",
306
+ "version": "1.0.0-next.49",
307
307
  "dependencies": {
308
308
  "@aws-amplify/adapter-nextjs": "^1.7.3",
309
309
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.48",
2
+ "version": "1.0.0-next.49",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",