@anthusai/papyrus 1.0.0-next.44 → 1.0.0-next.45

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.
@@ -1,5 +1,5 @@
1
1
  PyYAML>=6.0.2,<7
2
2
  citeproc-py>=0.8.2,<1
3
3
  anthus-markus==0.5.1
4
- Pillow>=10,<12
4
+ Pillow>=11.3,<12
5
5
  awscrt>=0.23,<1
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { pythonLambdaCode } from "../shared/python-bundle.js";
8
8
  const dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const projectRoot = path.resolve(dirname, "../../..");
10
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
10
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
11
11
  function installCheckoutRequirements(outputDir) {
12
12
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
13
13
  execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
10
10
  const dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const projectRoot = path.resolve(dirname, "../../..");
12
12
 
13
- export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=10,<12", "awscrt>=0.23,<1"];
13
+ export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
14
14
 
15
15
  function installCheckoutRequirements(outputDir: string): void {
16
16
  const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
10
+ from papyrus_content.renditions import (
11
+ RenditionSettings,
12
+ S3RenditionStore,
13
+ make_renditions,
14
+ meta_key,
15
+ settings_from_static_directory,
16
+ source_from_media_key,
17
+ )
18
+
19
+ _loaded: dict[str, Any] = {}
20
+
21
+
22
+ def configured_settings() -> RenditionSettings | None:
23
+ if "settings" not in _loaded:
24
+ static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
25
+ _loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
26
+ return _loaded["settings"]
27
+
28
+
29
+ def site_store() -> S3RenditionStore:
30
+ if "store" not in _loaded:
31
+ _loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
32
+ return _loaded["store"]
33
+
34
+
35
+ def s3_client():
36
+ if "s3" not in _loaded:
37
+ import boto3
38
+
39
+ _loaded["s3"] = boto3.client("s3")
40
+ return _loaded["s3"]
41
+
42
+
43
+ def media_objects(event: dict) -> list[tuple[str, str]]:
44
+ if event.get("source") == "aws.s3" and "detail" in event:
45
+ detail = event["detail"]
46
+ return [(detail["bucket"]["name"], detail["object"]["key"])]
47
+ if "bucket" in event and "key" in event:
48
+ return [(event["bucket"], event["key"])]
49
+ raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
50
+
51
+
52
+ def render_media_object(
53
+ settings: RenditionSettings, bucket: str, key: str, *, skip_existing: bool = False, dry_run: bool = False
54
+ ) -> dict:
55
+ source = source_from_media_key(key)
56
+ if source is None:
57
+ return {"key": key, "action": "ignored", "reason": "not an image under media/"}
58
+ if skip_existing and site_store().get(meta_key(settings.pipeline, source)) is not None:
59
+ return {"key": key, "source": source, "action": "skipped-existing"}
60
+ if dry_run:
61
+ return {"key": key, "source": source, "action": "would-render"}
62
+ started = time.perf_counter()
63
+ data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
64
+ downloaded = time.perf_counter()
65
+ result = make_renditions(settings, site_store(), source, data)
66
+ finished = time.perf_counter()
67
+ return {
68
+ "key": key,
69
+ "source": source,
70
+ "action": "rendered",
71
+ "kind": result["kind"],
72
+ "written": result["written"],
73
+ "sourceBytes": len(data),
74
+ "downloadMilliseconds": round((downloaded - started) * 1000),
75
+ "renditionMilliseconds": round((finished - downloaded) * 1000),
76
+ }
77
+
78
+
79
+ def handler(event, context):
80
+ settings = configured_settings()
81
+ if settings is None:
82
+ return {"ok": True, "results": [], "skipped": "this site has no images settings in projection.json"}
83
+ results = [
84
+ render_media_object(
85
+ settings,
86
+ bucket,
87
+ key,
88
+ skip_existing=event.get("skipExisting") is True,
89
+ dry_run=event.get("dryRun") is True,
90
+ )
91
+ for bucket, key in media_objects(event)
92
+ ]
93
+ print(json.dumps({"renditions": results}))
94
+ return {"ok": True, "results": results}
@@ -0,0 +1,4 @@
1
+ PyYAML>=6.0.2,<7
2
+ citeproc-py>=0.8.2,<1
3
+ anthus-markus==0.5.1
4
+ Pillow>=11.3,<12
@@ -0,0 +1,74 @@
1
+ import { Duration, Size } from "aws-cdk-lib";
2
+ import * as events from "aws-cdk-lib/aws-events";
3
+ import * as targets from "aws-cdk-lib/aws-events-targets";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as sqs from "aws-cdk-lib/aws-sqs";
7
+ import { execSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
11
+ import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
12
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const projectRoot = path.resolve(dirname, "../..");
14
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
15
+ function installCheckoutRequirements(outputDir) {
16
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
17
+ execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
18
+ }
19
+ export function addRenditionsFunction(scope, props) {
20
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
21
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
22
+ retentionPeriod: Duration.days(14),
23
+ enforceSSL: true,
24
+ });
25
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
26
+ runtime: Runtime.PYTHON_3_12,
27
+ architecture: Architecture.ARM_64,
28
+ handler: "handler.handler",
29
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
30
+ memorySize: RENDITIONS_MEMORY_MB,
31
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
32
+ retryAttempts: 2,
33
+ deadLetterQueue,
34
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
35
+ code: props.code ?? pythonLambdaCode({
36
+ functionDir: "renditions",
37
+ modules: ["papyrus_content"],
38
+ requirements: renditionsRequirements,
39
+ noDeps: true,
40
+ checkoutExtras: installCheckoutRequirements,
41
+ siteFiles: props.siteFiles,
42
+ }, [
43
+ "bash",
44
+ "-c",
45
+ [
46
+ "set -euo pipefail",
47
+ "mkdir -p /asset-output/papyrus_content",
48
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
49
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
50
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
51
+ ].join(" && "),
52
+ ]),
53
+ });
54
+ renditions.addToRolePolicy(new PolicyStatement({
55
+ effect: Effect.ALLOW,
56
+ actions: ["s3:GetObject"],
57
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
58
+ }));
59
+ renditions.addToRolePolicy(new PolicyStatement({
60
+ effect: Effect.ALLOW,
61
+ actions: ["s3:PutObject", "s3:GetObject"],
62
+ resources: [props.siteBucket.arnForObjects("*")],
63
+ }));
64
+ renditions.addToRolePolicy(new PolicyStatement({
65
+ effect: Effect.ALLOW,
66
+ actions: ["s3:ListBucket"],
67
+ resources: [props.siteBucket.bucketArn],
68
+ }));
69
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
70
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
71
+ });
72
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
73
+ return { function: renditions, rule, deadLetterQueue };
74
+ }
@@ -0,0 +1,112 @@
1
+ import { Duration, Size } from "aws-cdk-lib";
2
+ import * as events from "aws-cdk-lib/aws-events";
3
+ import * as targets from "aws-cdk-lib/aws-events-targets";
4
+ import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
5
+ import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
6
+ import * as s3 from "aws-cdk-lib/aws-s3";
7
+ import * as sqs from "aws-cdk-lib/aws-sqs";
8
+ import type { Construct } from "constructs";
9
+ import { execSync } from "node:child_process";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { pythonLambdaCode, type SiteBundleFile } from "../functions/shared/python-bundle";
13
+ import {
14
+ RENDITIONS_EPHEMERAL_STORAGE_MB,
15
+ RENDITIONS_MEDIA_PREFIX,
16
+ RENDITIONS_MEMORY_MB,
17
+ RENDITIONS_TIMEOUT_SECONDS,
18
+ renditionsEnvironment,
19
+ renditionsEventPattern,
20
+ } from "./renditions-settings";
21
+
22
+ const dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const projectRoot = path.resolve(dirname, "../..");
24
+
25
+ export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
26
+
27
+ export type RenditionsFunctionProps = {
28
+ siteBucket: s3.IBucket;
29
+ mediaBucket: s3.IBucket;
30
+ siteFiles: SiteBundleFile[];
31
+ code?: Code;
32
+ };
33
+
34
+ export type RenditionsFunction = {
35
+ function: LambdaFunction;
36
+ rule: events.Rule;
37
+ deadLetterQueue: sqs.Queue;
38
+ };
39
+
40
+ function installCheckoutRequirements(outputDir: string): void {
41
+ const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
42
+ execSync(
43
+ `python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`,
44
+ { cwd: projectRoot, stdio: "inherit" },
45
+ );
46
+ }
47
+
48
+ export function addRenditionsFunction(scope: Construct, props: RenditionsFunctionProps): RenditionsFunction {
49
+ const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
50
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
51
+ retentionPeriod: Duration.days(14),
52
+ enforceSSL: true,
53
+ });
54
+ const renditions = new LambdaFunction(scope, "RenditionsFunction", {
55
+ runtime: Runtime.PYTHON_3_12,
56
+ architecture: Architecture.ARM_64,
57
+ handler: "handler.handler",
58
+ timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
59
+ memorySize: RENDITIONS_MEMORY_MB,
60
+ ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
61
+ retryAttempts: 2,
62
+ deadLetterQueue,
63
+ environment: renditionsEnvironment(props.siteBucket.bucketName),
64
+ code: props.code ?? pythonLambdaCode(
65
+ {
66
+ functionDir: "renditions",
67
+ modules: ["papyrus_content"],
68
+ requirements: renditionsRequirements,
69
+ noDeps: true,
70
+ checkoutExtras: installCheckoutRequirements,
71
+ siteFiles: props.siteFiles,
72
+ },
73
+ [
74
+ "bash",
75
+ "-c",
76
+ [
77
+ "set -euo pipefail",
78
+ "mkdir -p /asset-output/papyrus_content",
79
+ "cp amplify/functions/renditions/handler.py /asset-output/handler.py",
80
+ "cp -R src/papyrus_content/. /asset-output/papyrus_content/",
81
+ "python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
82
+ ].join(" && "),
83
+ ],
84
+ ),
85
+ });
86
+ renditions.addToRolePolicy(
87
+ new PolicyStatement({
88
+ effect: Effect.ALLOW,
89
+ actions: ["s3:GetObject"],
90
+ resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
91
+ }),
92
+ );
93
+ renditions.addToRolePolicy(
94
+ new PolicyStatement({
95
+ effect: Effect.ALLOW,
96
+ actions: ["s3:PutObject", "s3:GetObject"],
97
+ resources: [props.siteBucket.arnForObjects("*")],
98
+ }),
99
+ );
100
+ renditions.addToRolePolicy(
101
+ new PolicyStatement({
102
+ effect: Effect.ALLOW,
103
+ actions: ["s3:ListBucket"],
104
+ resources: [props.siteBucket.bucketArn],
105
+ }),
106
+ );
107
+ const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
108
+ eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
109
+ });
110
+ rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
111
+ return { function: renditions, rule, deadLetterQueue };
112
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section: that section is the one place the image pipeline settings
7
+ * live, shared by the renditions function and the projector.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const RENDITIONS_MEMORY_MB = 3008;
12
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
13
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
14
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
15
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
16
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
17
+ export function projectionSettingsHaveImages(settingsPath) {
18
+ if (!fs.existsSync(settingsPath))
19
+ return false;
20
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
21
+ return typeof parsed.images === "object" && parsed.images !== null;
22
+ }
23
+ export function planRenditions(publication, workingDirectory) {
24
+ if (publication?.staticDir === undefined)
25
+ return { enabled: false, siteFiles: [] };
26
+ const settingsFile = path.join(publication.staticDir, "projection.json");
27
+ if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
28
+ return { enabled: false, siteFiles: [] };
29
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
30
+ }
31
+ export function renditionsEventPattern(mediaBucketName) {
32
+ return {
33
+ source: ["aws.s3"],
34
+ detailType: ["Object Created"],
35
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
36
+ };
37
+ }
38
+ export function renditionsEnvironment(siteBucketName) {
39
+ return {
40
+ PAPYRUS_SITE_BUCKET: siteBucketName,
41
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
42
+ };
43
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pure planning for the upload-time renditions function of an s3-projection
3
+ * site (no CDK imports, so it can be tested without synthesizing).
4
+ *
5
+ * The function exists only for a site whose `projection.json` carries an
6
+ * `images` section: that section is the one place the image pipeline settings
7
+ * live, shared by the renditions function and the projector.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ export const RENDITIONS_MEMORY_MB = 3008;
13
+ export const RENDITIONS_TIMEOUT_SECONDS = 300;
14
+ export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
15
+ export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
16
+ export const RENDITIONS_MEDIA_PREFIX = "media/";
17
+ export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
18
+
19
+ export type RenditionsPlan = {
20
+ enabled: boolean;
21
+ siteFiles: Array<{ from: string; to: string }>;
22
+ };
23
+
24
+ export function projectionSettingsHaveImages(settingsPath: string): boolean {
25
+ if (!fs.existsSync(settingsPath)) return false;
26
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
27
+ return typeof parsed.images === "object" && parsed.images !== null;
28
+ }
29
+
30
+ export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
31
+ if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
32
+ const settingsFile = path.join(publication.staticDir, "projection.json");
33
+ if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
34
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
35
+ }
36
+
37
+ export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
38
+ return {
39
+ source: ["aws.s3"],
40
+ detailType: ["Object Created"],
41
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
42
+ };
43
+ }
44
+
45
+ export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
46
+ return {
47
+ PAPYRUS_SITE_BUCKET: siteBucketName,
48
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
49
+ };
50
+ }
@@ -9,6 +9,8 @@ import * as sqs from "aws-cdk-lib/aws-sqs";
9
9
  import { dirname, resolve } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { assertValidPublicationConfig } from "../site-backend-config.js";
12
+ import { addRenditionsFunction } from "./renditions-function.js";
13
+ import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
12
14
  import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
13
15
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
14
16
  function createSiteBucket(scope, retainBucketOnDelete) {
@@ -60,7 +62,7 @@ function createDistribution(scope, props, bucket, redirectFunction) {
60
62
  ],
61
63
  });
62
64
  }
63
- export function addS3ProjectionPublication(scope, props) {
65
+ export function addS3ProjectionPublication(scope, props, media) {
64
66
  assertValidPublicationConfig({ kind: "s3-projection", ...props });
65
67
  const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
66
68
  const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
@@ -90,6 +92,9 @@ export function addS3ProjectionPublication(scope, props) {
90
92
  conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
91
93
  }));
92
94
  }
95
+ const renditions = media?.renditions.enabled === true
96
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
97
+ : undefined;
93
98
  const settingsInput = {
94
99
  bucketName: bucket.bucketName,
95
100
  bucketArn: bucket.bucketArn,
@@ -111,7 +116,11 @@ export function addS3ProjectionPublication(scope, props) {
111
116
  deadLetterQueue,
112
117
  distribution,
113
118
  distributionId: distribution?.distributionId,
114
- environment: s3ProjectionEnvironment(settingsInput),
119
+ renditions,
120
+ environment: {
121
+ ...s3ProjectionEnvironment(settingsInput),
122
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
123
+ },
115
124
  policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
116
125
  };
117
126
  }
@@ -1,6 +1,7 @@
1
1
  import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
2
  import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
3
  import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
+ import type { Code } from "aws-cdk-lib/aws-lambda";
4
5
  import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
6
  import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
6
7
  import * as route53 from "aws-cdk-lib/aws-route53";
@@ -10,12 +11,20 @@ import type { Construct } from "constructs";
10
11
  import { dirname, resolve } from "node:path";
11
12
  import { fileURLToPath } from "node:url";
12
13
  import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
14
+ import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
15
+ import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
13
16
  import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
14
17
 
15
18
  const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
16
19
 
17
20
  export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
18
21
 
22
+ export type S3ProjectionMedia = {
23
+ mediaBucket: s3.IBucket;
24
+ renditions: RenditionsPlan;
25
+ renditionsCode?: Code;
26
+ };
27
+
19
28
  export type S3ProjectionPublication = {
20
29
  bucket: s3.Bucket;
21
30
  keyValueStore: cloudfront.KeyValueStore;
@@ -23,6 +32,7 @@ export type S3ProjectionPublication = {
23
32
  deadLetterQueue: sqs.Queue;
24
33
  distribution?: cloudfront.Distribution;
25
34
  distributionId?: string;
35
+ renditions?: RenditionsFunction;
26
36
  environment: Record<string, string>;
27
37
  policyStatements: PolicyStatement[];
28
38
  };
@@ -88,7 +98,11 @@ function createDistribution(
88
98
  });
89
99
  }
90
100
 
91
- export function addS3ProjectionPublication(scope: Construct, props: S3ProjectionPublicationProps): S3ProjectionPublication {
101
+ export function addS3ProjectionPublication(
102
+ scope: Construct,
103
+ props: S3ProjectionPublicationProps,
104
+ media?: S3ProjectionMedia,
105
+ ): S3ProjectionPublication {
92
106
  assertValidPublicationConfig({ kind: "s3-projection", ...props });
93
107
  const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
94
108
 
@@ -123,6 +137,10 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
123
137
  );
124
138
  }
125
139
 
140
+ const renditions = media?.renditions.enabled === true
141
+ ? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
142
+ : undefined;
143
+
126
144
  const settingsInput = {
127
145
  bucketName: bucket.bucketName,
128
146
  bucketArn: bucket.bucketArn,
@@ -144,7 +162,11 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
144
162
  deadLetterQueue,
145
163
  distribution,
146
164
  distributionId: distribution?.distributionId,
147
- environment: s3ProjectionEnvironment(settingsInput),
165
+ renditions,
166
+ environment: {
167
+ ...s3ProjectionEnvironment(settingsInput),
168
+ ...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
169
+ },
148
170
  policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
149
171
  (statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
150
172
  ),
@@ -8,6 +8,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
10
  import { addS3ProjectionPublication } from "./publication/s3-projection.js";
11
+ import { planRenditions } from "./publication/renditions-settings.js";
11
12
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
12
13
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
13
14
  import { assertValidPublicationConfig } from "./site-backend-config.js";
@@ -68,6 +69,7 @@ export function defineSiteBackend(papyrusSite) {
68
69
  if (site.publication !== undefined)
69
70
  assertValidPublicationConfig(site.publication);
70
71
  const publicationPlan = planS3ProjectionBackend(site.publication);
72
+ const renditionsPlan = planRenditions(site.publication, process.cwd());
71
73
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
72
74
  const backend = defineBackend({
73
75
  assignmentAction,
@@ -224,8 +226,8 @@ export function defineSiteBackend(papyrusSite) {
224
226
  let storageBackupPlan;
225
227
  let storageBackupVault;
226
228
  const storageBucketCfn = storageBucket.node.defaultChild;
227
- if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
228
- // Backups and inbound-email intake both rely on S3 → EventBridge notifications.
229
+ if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
230
+ // Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
229
231
  storageBucketCfn.addPropertyOverride("NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled", true);
230
232
  }
231
233
  if (enableStorageBackups) {
@@ -311,7 +313,10 @@ export function defineSiteBackend(papyrusSite) {
311
313
  }
312
314
  let s3ProjectionPublication;
313
315
  if (site.publication !== undefined) {
314
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
316
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
317
+ mediaBucket: storageBucket,
318
+ renditions: renditionsPlan,
319
+ });
315
320
  contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
316
321
  }
317
322
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
@@ -11,6 +11,7 @@ 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
13
  import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
14
+ import { planRenditions } from "./publication/renditions-settings";
14
15
  import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
15
16
  import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
16
17
  import { assertValidPublicationConfig } from "./site-backend-config";
@@ -85,6 +86,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
85
86
  };
86
87
  if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
87
88
  const publicationPlan = planS3ProjectionBackend(site.publication);
89
+ const renditionsPlan = planRenditions(site.publication, process.cwd());
88
90
  setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
89
91
  const backend = defineBackend({
90
92
  assignmentAction,
@@ -285,8 +287,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
285
287
  let storageBackupVault: backup.BackupVault | undefined;
286
288
 
287
289
  const storageBucketCfn = storageBucket.node.defaultChild as s3.CfnBucket | undefined;
288
- if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
289
- // Backups and inbound-email intake both rely on S3 → EventBridge notifications.
290
+ if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
291
+ // Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
290
292
  storageBucketCfn.addPropertyOverride(
291
293
  "NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled",
292
294
  true,
@@ -396,7 +398,10 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
396
398
  }
397
399
  let s3ProjectionPublication: S3ProjectionPublication | undefined;
398
400
  if (site.publication !== undefined) {
399
- s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication);
401
+ s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
402
+ mediaBucket: storageBucket,
403
+ renditions: renditionsPlan,
404
+ });
400
405
  (contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
401
406
  }
402
407
  const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
package/package.json CHANGED
@@ -302,7 +302,7 @@
302
302
  "optional": true
303
303
  }
304
304
  },
305
- "version": "1.0.0-next.44",
305
+ "version": "1.0.0-next.45",
306
306
  "dependencies": {
307
307
  "@aws-amplify/adapter-nextjs": "^1.7.3",
308
308
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.44",
2
+ "version": "1.0.0-next.45",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",