@anthusai/papyrus 1.0.0-next.9 → 1.0.0

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 (208) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/components/amplify-client-provider.tsx +5 -22
  93. package/components/archive-shell.tsx +2 -22
  94. package/components/article-video.tsx +152 -5
  95. package/components/blog-page-background.tsx +23 -6
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +90 -3
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-board-view.tsx +99 -0
  101. package/components/newsroom-client-shell.tsx +12 -0
  102. package/components/newsroom-curation-provider.tsx +17 -0
  103. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  104. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  106. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  109. package/components/newsroom-newsprint-board.css +48 -0
  110. package/components/newsroom-ops-overview.tsx +28 -11
  111. package/components/newsroom-ops-shell.tsx +7 -1
  112. package/components/newsroom-organizer-shell.tsx +130 -0
  113. package/components/newsroom-organizer.css +63 -0
  114. package/components/newsroom-references-view.tsx +92 -8
  115. package/components/papyrus-console-shell.tsx +4 -2
  116. package/components/pictogram-figure.tsx +9 -2
  117. package/components/presentation-header.tsx +135 -0
  118. package/components/presentation-rhythm-hrule.tsx +17 -0
  119. package/components/priming-proof-page.tsx +92 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +78 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +148 -27
  134. package/infra/amplify-app-shell.ts +160 -31
  135. package/infra/build-specs.js +23 -7
  136. package/infra/build-specs.ts +27 -10
  137. package/infra/site-config.js +248 -36
  138. package/infra/site-config.ts +313 -51
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/approved-priming-proof-reports.ts +6 -0
  142. package/lib/blog-feature-solver.ts +459 -0
  143. package/lib/blog-rhythm.ts +134 -0
  144. package/lib/brand-fonts-watch.ts +43 -0
  145. package/lib/brand-fonts.ts +112 -0
  146. package/lib/cached-content-repository.ts +12 -0
  147. package/lib/category-repository.ts +43 -93
  148. package/lib/content-item-lifecycle.ts +55 -0
  149. package/lib/content-repository.ts +3 -0
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +56 -22
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +99 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-nav.ts +3 -1
  162. package/lib/newsroom-organizer-nav.ts +37 -0
  163. package/lib/newsroom-section-routing.ts +20 -0
  164. package/lib/oauth-redirect-priority.ts +40 -0
  165. package/lib/papyrus-web-locations.ts +7 -6
  166. package/lib/plugin-settings.ts +11 -0
  167. package/lib/presentation-footer.ts +3 -2
  168. package/lib/pretext-layout.ts +12 -3
  169. package/lib/priming-proof-report.ts +89 -0
  170. package/lib/prospectus-site-identity.ts +303 -0
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +59 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +64 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +68 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,40 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { signupSubmit } from "./function/resource.js";
3
+ /**
4
+ * SignupEntry is never guest-readable and has no create rule for anyone: only the
5
+ * submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
6
+ */
7
+ export function signupSchemaDefinitions() {
8
+ return {
9
+ SignupEntry: a
10
+ .model({
11
+ id: a.id().required(),
12
+ form: a.string().required(),
13
+ value: a.string().required(),
14
+ submittedAt: a.datetime().required(),
15
+ })
16
+ .disableOperations(["subscriptions"])
17
+ .secondaryIndexes((index) => [
18
+ index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
19
+ ])
20
+ .authorization((allow) => [
21
+ allow.group("editor").to(["read"]),
22
+ allow.group("admin").to(["read", "delete"]),
23
+ ]),
24
+ SignupReceipt: a.customType({
25
+ status: a.string().required(),
26
+ message: a.string(),
27
+ }),
28
+ submitSignup: a
29
+ .mutation()
30
+ .arguments({
31
+ form: a.string().required(),
32
+ value: a.string().required(),
33
+ renderedAt: a.string().required(),
34
+ website: a.string(),
35
+ })
36
+ .returns(a.ref("SignupReceipt"))
37
+ .authorization((allow) => [allow.guest()])
38
+ .handler(a.handler.function(signupSubmit)),
39
+ };
40
+ }
@@ -0,0 +1,41 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { signupSubmit } from "./function/resource";
3
+
4
+ /**
5
+ * SignupEntry is never guest-readable and has no create rule for anyone: only the
6
+ * submitSignup function (IAM) writes it. Editors and admins list it; admins delete.
7
+ */
8
+ export function signupSchemaDefinitions() {
9
+ return {
10
+ SignupEntry: a
11
+ .model({
12
+ id: a.id().required(),
13
+ form: a.string().required(),
14
+ value: a.string().required(),
15
+ submittedAt: a.datetime().required(),
16
+ })
17
+ .disableOperations(["subscriptions"])
18
+ .secondaryIndexes((index) => [
19
+ index("form").sortKeys(["submittedAt"]).queryField("listSignupEntriesByFormAndSubmittedAt"),
20
+ ])
21
+ .authorization((allow) => [
22
+ allow.group("editor").to(["read"]),
23
+ allow.group("admin").to(["read", "delete"]),
24
+ ]),
25
+ SignupReceipt: a.customType({
26
+ status: a.string().required(),
27
+ message: a.string(),
28
+ }),
29
+ submitSignup: a
30
+ .mutation()
31
+ .arguments({
32
+ form: a.string().required(),
33
+ value: a.string().required(),
34
+ renderedAt: a.string().required(),
35
+ website: a.string(),
36
+ })
37
+ .returns(a.ref("SignupReceipt"))
38
+ .authorization((allow) => [allow.guest()])
39
+ .handler(a.handler.function(signupSubmit)),
40
+ };
41
+ }
@@ -0,0 +1,48 @@
1
+ import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
2
+ export const SIGNUP_MUTATION_FIELD = "submitSignup";
3
+ export const WAF_MINIMUM_RATE_LIMIT = 10;
4
+ export const WAF_EVALUATION_WINDOW_SECONDS = 60;
5
+ /**
6
+ * A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
7
+ * submitSignup requests from one address in a minute are blocked with a 429 before
8
+ * the function runs. Only requests whose body names the mutation count, so reads,
9
+ * newsroom saves and CLI imports from the same address are never limited.
10
+ */
11
+ export function addSignupRateLimit(scope, input) {
12
+ if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
13
+ throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
14
+ }
15
+ const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
16
+ scope: "REGIONAL",
17
+ defaultAction: { allow: {} },
18
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
19
+ rules: [
20
+ {
21
+ name: "SignupRateLimitPerAddress",
22
+ priority: 0,
23
+ action: { block: { customResponse: { responseCode: 429 } } },
24
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
25
+ statement: {
26
+ rateBasedStatement: {
27
+ limit: input.limit,
28
+ evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
29
+ aggregateKeyType: "IP",
30
+ scopeDownStatement: {
31
+ byteMatchStatement: {
32
+ searchString: SIGNUP_MUTATION_FIELD,
33
+ positionalConstraint: "CONTAINS",
34
+ fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
35
+ textTransformations: [{ priority: 0, type: "NONE" }],
36
+ },
37
+ },
38
+ },
39
+ },
40
+ },
41
+ ],
42
+ });
43
+ new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
44
+ resourceArn: input.graphqlApiArn,
45
+ webAclArn: acl.attrArn,
46
+ });
47
+ return acl;
48
+ }
@@ -0,0 +1,51 @@
1
+ import { CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
2
+ import type { Construct } from "constructs";
3
+
4
+ export const SIGNUP_MUTATION_FIELD = "submitSignup";
5
+ export const WAF_MINIMUM_RATE_LIMIT = 10;
6
+ export const WAF_EVALUATION_WINDOW_SECONDS = 60;
7
+
8
+ /**
9
+ * A regional web ACL on the AppSync API with one rate-based rule: more than `limit`
10
+ * submitSignup requests from one address in a minute are blocked with a 429 before
11
+ * the function runs. Only requests whose body names the mutation count, so reads,
12
+ * newsroom saves and CLI imports from the same address are never limited.
13
+ */
14
+ export function addSignupRateLimit(scope: Construct, input: { graphqlApiArn: string; limit: number }): CfnWebACL {
15
+ if (!Number.isInteger(input.limit) || input.limit < WAF_MINIMUM_RATE_LIMIT) {
16
+ throw new Error(`signup waf rate limit must be an integer of at least ${WAF_MINIMUM_RATE_LIMIT} (AWS WAF minimum)`);
17
+ }
18
+ const acl = new CfnWebACL(scope, "PapyrusSignupWebAcl", {
19
+ scope: "REGIONAL",
20
+ defaultAction: { allow: {} },
21
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "PapyrusSignupWebAcl", sampledRequestsEnabled: true },
22
+ rules: [
23
+ {
24
+ name: "SignupRateLimitPerAddress",
25
+ priority: 0,
26
+ action: { block: { customResponse: { responseCode: 429 } } },
27
+ visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: "SignupRateLimitPerAddress", sampledRequestsEnabled: true },
28
+ statement: {
29
+ rateBasedStatement: {
30
+ limit: input.limit,
31
+ evaluationWindowSec: WAF_EVALUATION_WINDOW_SECONDS,
32
+ aggregateKeyType: "IP",
33
+ scopeDownStatement: {
34
+ byteMatchStatement: {
35
+ searchString: SIGNUP_MUTATION_FIELD,
36
+ positionalConstraint: "CONTAINS",
37
+ fieldToMatch: { body: { oversizeHandling: "NO_MATCH" } },
38
+ textTransformations: [{ priority: 0, type: "NONE" }],
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ ],
45
+ });
46
+ new CfnWebACLAssociation(scope, "PapyrusSignupWebAclAssociation", {
47
+ resourceArn: input.graphqlApiArn,
48
+ webAclArn: acl.attrArn,
49
+ });
50
+ return acl;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import type { Construct } from "constructs";
2
+
3
+ /** How a site asks for a plugin in `papyrus.config.ts`: `plugins: ["signup"]` or `plugins: [{ name: "signup", forms: ["news"] }]`. */
4
+ export type PluginRequest = string | ({ name: string } & Record<string, unknown>);
5
+
6
+ /** What a plugin declares about itself. A test pins the schema, the guest rules and the static files to this. */
7
+ export type PluginManifest = {
8
+ name: string;
9
+ /** Public contract version. Bump it when a mutation input, a model field, an element name or an attribute changes. No compatibility shims. */
10
+ contract: number;
11
+ models: string[];
12
+ mutations: string[];
13
+ /** What an anonymous visitor may call. Everything else is closed to guests. */
14
+ guest: { mutations: string[] };
15
+ newsroomPanels: { id: string; label: string }[];
16
+ /** Keys the projector writes into the site bucket when the plugin is enabled. */
17
+ staticAssets: string[];
18
+ };
19
+
20
+ /** Where the deploy-time wiring puts its resources. Filled in by `defineSiteBackend`. */
21
+ export type PluginWiringContext = {
22
+ createStack: (name: string) => Construct;
23
+ graphqlApiId: string;
24
+ graphqlEndpoint: string;
25
+ };
26
+
27
+ export type PapyrusPlugin<Config, Functions = unknown> = {
28
+ manifest: PluginManifest;
29
+ /** Validates the request and fills in defaults. Throws a readable error. */
30
+ resolveConfig: (request: PluginRequest) => Config;
31
+ /** Amplify function resources the plugin adds to `defineBackend`. */
32
+ functions: () => Functions;
33
+ /** Models and custom operations merged into the site schema. */
34
+ schemaDefinitions: () => Record<string, unknown>;
35
+ /** The settings the newsroom and the page component may read from `amplify_outputs.json` (`custom.papyrusPlugins.<name>`). Never secrets. */
36
+ publicSettings: (config: Config) => Record<string, unknown>;
37
+ /** Everything that needs the backend to exist: IAM, environment, WAF, outputs. */
38
+ wire: (backend: any, config: Config, context: PluginWiringContext) => void;
39
+ };
@@ -0,0 +1,24 @@
1
+ export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
2
+ export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
3
+ /**
4
+ * Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
5
+ * nothing is added to the stacks, the environment or the outputs.
6
+ */
7
+ export function wirePlugins(backend, plugins, contentActionsLambda) {
8
+ if (plugins.length === 0)
9
+ return;
10
+ const graphqlApi = backend.data.resources.graphqlApi;
11
+ const context = {
12
+ createStack: (name) => backend.createStack(name),
13
+ graphqlApiId: graphqlApi.apiId,
14
+ graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
15
+ };
16
+ for (const { plugin, config } of plugins)
17
+ plugin.wire(backend, config, context);
18
+ contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
19
+ backend.addOutput({
20
+ custom: {
21
+ [PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
22
+ },
23
+ });
24
+ }
@@ -0,0 +1,26 @@
1
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
2
+ import type { ResolvedPlugin } from "./registry";
3
+
4
+ export const PLUGINS_ENVIRONMENT_VARIABLE = "PAPYRUS_PLUGINS";
5
+ export const PLUGINS_OUTPUT_KEY = "papyrusPlugins";
6
+
7
+ /**
8
+ * Deploy-time wiring for every enabled plugin. A site without plugins reaches no line of this:
9
+ * nothing is added to the stacks, the environment or the outputs.
10
+ */
11
+ export function wirePlugins(backend: any, plugins: ResolvedPlugin[], contentActionsLambda: LambdaFunction): void {
12
+ if (plugins.length === 0) return;
13
+ const graphqlApi = backend.data.resources.graphqlApi;
14
+ const context = {
15
+ createStack: (name: string) => backend.createStack(name),
16
+ graphqlApiId: graphqlApi.apiId as string,
17
+ graphqlEndpoint: backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string,
18
+ };
19
+ for (const { plugin, config } of plugins) plugin.wire(backend, config, context);
20
+ contentActionsLambda.addEnvironment(PLUGINS_ENVIRONMENT_VARIABLE, plugins.map(({ plugin }) => plugin.manifest.name).join(","));
21
+ backend.addOutput({
22
+ custom: {
23
+ [PLUGINS_OUTPUT_KEY]: Object.fromEntries(plugins.map(({ plugin, config }) => [plugin.manifest.name, plugin.publicSettings(config)])),
24
+ },
25
+ });
26
+ }
@@ -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,26 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
4
+ export const DAYS_PER_MONTH = 30.4;
5
+ export const ALARM_WINDOW_DAYS = 7;
6
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
7
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
8
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
9
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
10
+ export function addInvalidationPathAlarm(scope, id, distributionId) {
11
+ const metric = new cloudwatch.Metric({
12
+ namespace: INVALIDATION_METRIC_NAMESPACE,
13
+ metricName: INVALIDATION_METRIC_NAME,
14
+ dimensionsMap: { DistributionId: distributionId },
15
+ statistic: "Sum",
16
+ period: Duration.days(ALARM_WINDOW_DAYS),
17
+ });
18
+ return new cloudwatch.Alarm(scope, id, {
19
+ metric,
20
+ evaluationPeriods: 1,
21
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
22
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
23
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
24
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
25
+ });
26
+ }
@@ -0,0 +1,30 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
3
+ import type { Construct } from "constructs";
4
+
5
+ export const FREE_INVALIDATION_PATHS_PER_MONTH = 1000;
6
+ export const DAYS_PER_MONTH = 30.4;
7
+ export const ALARM_WINDOW_DAYS = 7;
8
+ export const INVALIDATION_METRIC_NAMESPACE = "Papyrus/Edge";
9
+ export const INVALIDATION_METRIC_NAME = "InvalidatedPaths";
10
+
11
+ /** CloudWatch cannot alarm across more than seven days, so the monthly free allowance is checked as its weekly pace. */
12
+ export const WEEKLY_INVALIDATION_PATH_THRESHOLD = Math.round((FREE_INVALIDATION_PATHS_PER_MONTH * ALARM_WINDOW_DAYS) / DAYS_PER_MONTH);
13
+
14
+ export function addInvalidationPathAlarm(scope: Construct, id: string, distributionId: string): cloudwatch.Alarm {
15
+ const metric = new cloudwatch.Metric({
16
+ namespace: INVALIDATION_METRIC_NAMESPACE,
17
+ metricName: INVALIDATION_METRIC_NAME,
18
+ dimensionsMap: { DistributionId: distributionId },
19
+ statistic: "Sum",
20
+ period: Duration.days(ALARM_WINDOW_DAYS),
21
+ });
22
+ return new cloudwatch.Alarm(scope, id, {
23
+ metric,
24
+ evaluationPeriods: 1,
25
+ threshold: WEEKLY_INVALIDATION_PATH_THRESHOLD,
26
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
27
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
28
+ alarmDescription: `CloudFront invalidation paths on ${distributionId} are on pace to pass the ${FREE_INVALIDATION_PATHS_PER_MONTH} free paths a month.`,
29
+ });
30
+ }
@@ -0,0 +1,37 @@
1
+ import cf from "cloudfront";
2
+
3
+ const redirectStore = cf.kvs();
4
+
5
+ async function lookUpRedirectTarget(uri) {
6
+ try {
7
+ const target = await redirectStore.get(uri);
8
+ return target ? String(target) : null;
9
+ } catch (error) {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function rewriteToIndexDocument(uri) {
15
+ if (uri.endsWith("/")) {
16
+ return uri + "index.html";
17
+ }
18
+ const lastSegment = uri.substring(uri.lastIndexOf("/") + 1);
19
+ if (lastSegment.indexOf(".") === -1) {
20
+ return uri + "/index.html";
21
+ }
22
+ return uri;
23
+ }
24
+
25
+ async function handler(event) {
26
+ const request = event.request;
27
+ const redirectTarget = await lookUpRedirectTarget(request.uri);
28
+ if (redirectTarget !== null) {
29
+ return {
30
+ statusCode: 301,
31
+ statusDescription: "Moved Permanently",
32
+ headers: { location: { value: redirectTarget } },
33
+ };
34
+ }
35
+ request.uri = rewriteToIndexDocument(request.uri);
36
+ return request;
37
+ }
@@ -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,51 @@
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 (the one place the image pipeline settings live, shared by
7
+ * the renditions function and the projector) or `media.originals: true`
8
+ * (every media object is also published as is, at its key without `media/`).
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
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
+ export function projectionSettingsHaveImages(settingsPath) {
19
+ if (!fs.existsSync(settingsPath))
20
+ return false;
21
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
22
+ return typeof parsed.images === "object" && parsed.images !== null;
23
+ }
24
+ export function projectionSettingsPublishOriginals(settingsPath) {
25
+ if (!fs.existsSync(settingsPath))
26
+ return false;
27
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
28
+ return parsed.media?.originals === true;
29
+ }
30
+ export function planRenditions(publication, workingDirectory) {
31
+ if (publication?.staticDir === undefined)
32
+ return { enabled: false, siteFiles: [] };
33
+ const settingsFile = path.join(publication.staticDir, "projection.json");
34
+ const resolved = path.resolve(workingDirectory, settingsFile);
35
+ if (!projectionSettingsHaveImages(resolved) && !projectionSettingsPublishOriginals(resolved))
36
+ return { enabled: false, siteFiles: [] };
37
+ return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
38
+ }
39
+ export function renditionsEventPattern(mediaBucketName) {
40
+ return {
41
+ source: ["aws.s3"],
42
+ detailType: ["Object Created"],
43
+ detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
44
+ };
45
+ }
46
+ export function renditionsEnvironment(siteBucketName) {
47
+ return {
48
+ PAPYRUS_SITE_BUCKET: siteBucketName,
49
+ PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
50
+ };
51
+ }