@anthusai/papyrus 1.0.0-next.40 → 1.0.0-next.41

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,6 +7,18 @@ import { isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, r
7
7
  function environmentVariables(variables) {
8
8
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
9
9
  }
10
+ function readerCustomHeaders(rules) {
11
+ if (!rules)
12
+ return undefined;
13
+ const lines = ["customHeaders:"];
14
+ for (const rule of rules) {
15
+ lines.push(` - pattern: ${JSON.stringify(rule.pattern)}`, " headers:");
16
+ for (const [key, value] of Object.entries(rule.headers)) {
17
+ lines.push(` - key: ${JSON.stringify(key)}`, ` value: ${JSON.stringify(value)}`);
18
+ }
19
+ }
20
+ return lines.join("\n") + "\n";
21
+ }
10
22
  /**
11
23
  * Provisions the Amplify *app shell* of a Papyrus publication from its
12
24
  * site.json:
@@ -282,7 +294,11 @@ export class AmplifyAppShellStack extends Stack {
282
294
  platform: "WEB",
283
295
  iamServiceRole: readerServiceRole.roleArn,
284
296
  buildSpec: readerBuildSpec(config),
285
- customRules: [{ source: "/<*>", target: "/404.html", status: "404-200" }],
297
+ customRules: [
298
+ ...(reader.redirects ?? []),
299
+ { source: "/<*>", target: "/404.html", status: "404-200" },
300
+ ],
301
+ customHeaders: readerCustomHeaders(reader.customHeaders),
286
302
  });
287
303
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
288
304
  appId: readerApp.attrAppId,
@@ -299,7 +315,7 @@ export class AmplifyAppShellStack extends Stack {
299
315
  const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
300
316
  appId: readerApp.attrAppId,
301
317
  domainName: reader.domainName,
302
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
318
+ subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
303
319
  });
304
320
  readerDomain.addResourceDependency(readerBranch);
305
321
  }
@@ -4,12 +4,24 @@ import * as iam from "aws-cdk-lib/aws-iam";
4
4
  import * as amplify from "aws-cdk-lib/aws-amplify";
5
5
  import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
6
6
  import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
7
- import { AmplifyAppShellSiteConfig, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
7
+ import { AmplifyAppShellSiteConfig, ReaderCustomHeaderRule, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
8
8
 
9
9
  function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
10
10
  return Object.entries(variables).map(([name, value]) => ({ name, value }));
11
11
  }
12
12
 
13
+ function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
14
+ if (!rules) return undefined;
15
+ const lines = ["customHeaders:"];
16
+ for (const rule of rules) {
17
+ lines.push(` - pattern: ${JSON.stringify(rule.pattern)}`, " headers:");
18
+ for (const [key, value] of Object.entries(rule.headers)) {
19
+ lines.push(` - key: ${JSON.stringify(key)}`, ` value: ${JSON.stringify(value)}`);
20
+ }
21
+ }
22
+ return lines.join("\n") + "\n";
23
+ }
24
+
13
25
  /**
14
26
  * Provisions the Amplify *app shell* of a Papyrus publication from its
15
27
  * site.json:
@@ -333,7 +345,11 @@ export class AmplifyAppShellStack extends Stack {
333
345
  platform: "WEB",
334
346
  iamServiceRole: readerServiceRole.roleArn,
335
347
  buildSpec: readerBuildSpec(config),
336
- customRules: [{ source: "/<*>", target: "/404.html", status: "404-200" }],
348
+ customRules: [
349
+ ...(reader.redirects ?? []),
350
+ { source: "/<*>", target: "/404.html", status: "404-200" },
351
+ ],
352
+ customHeaders: readerCustomHeaders(reader.customHeaders),
337
353
  });
338
354
 
339
355
  const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
@@ -352,7 +368,7 @@ export class AmplifyAppShellStack extends Stack {
352
368
  const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
353
369
  appId: readerApp.attrAppId,
354
370
  domainName: reader.domainName,
355
- subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
371
+ subDomainSettings: (reader.domainPrefixes ?? [""]).map((prefix) => ({ branchName: readerBranchName, prefix })),
356
372
  });
357
373
  readerDomain.addResourceDependency(readerBranch);
358
374
  }
@@ -82,7 +82,8 @@ export function readerBuildSpec(config) {
82
82
  "version: 1",
83
83
  ...frontendSection([
84
84
  ...pythonProvisioningCommands(config),
85
- "papyrus ops content export-published --auth guest --out content-export --clean",
85
+ ...(readerConfig.preBuildCommands ?? []),
86
+ ...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
86
87
  ], [readerConfig.buildCommand], readerConfig.baseDirectory, [".uv-cache/**/*"]),
87
88
  ].join("\n") + "\n";
88
89
  }
@@ -126,7 +126,8 @@ export function readerBuildSpec(config: AmplifyAppShellSiteConfig): string {
126
126
  ...frontendSection(
127
127
  [
128
128
  ...pythonProvisioningCommands(config),
129
- "papyrus ops content export-published --auth guest --out content-export --clean",
129
+ ...(readerConfig.preBuildCommands ?? []),
130
+ ...(readerConfig.skipContentExport ? [] : ["papyrus ops content export-published --auth guest --out content-export --clean"]),
130
131
  ],
131
132
  [readerConfig.buildCommand],
132
133
  readerConfig.baseDirectory,
@@ -1,3 +1,4 @@
1
+ export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
1
2
  export const DEFAULT_CI_BRANCHES = ["main", "staging"];
2
3
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
3
4
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -78,6 +79,63 @@ function parseRedirects(value) {
78
79
  return { source, status };
79
80
  });
80
81
  }
82
+ function parseReaderDomainPrefixes(value) {
83
+ if (value === undefined)
84
+ return undefined;
85
+ if (!Array.isArray(value) || value.length === 0)
86
+ fail("reader.domainPrefixes", "must be a non-empty array");
87
+ const prefixes = value.map((entry, index) => {
88
+ const field = `reader.domainPrefixes[${index}]`;
89
+ if (entry === "")
90
+ return "";
91
+ return requireString(entry, field, DOMAIN_PREFIX_PATTERN, 'single DNS label, or "" for the apex');
92
+ });
93
+ if (new Set(prefixes).size !== prefixes.length)
94
+ fail("reader.domainPrefixes", "must not repeat a prefix");
95
+ return prefixes;
96
+ }
97
+ function parseReaderRedirects(value) {
98
+ if (value === undefined)
99
+ return undefined;
100
+ if (!Array.isArray(value) || value.length === 0)
101
+ fail("reader.redirects", "must be a non-empty array");
102
+ return value.map((entry, index) => {
103
+ const field = `reader.redirects[${index}]`;
104
+ const record = requireObject(entry, field);
105
+ rejectUnknownKeys(record, ["source", "target", "status"], field);
106
+ const status = record.status ?? "301";
107
+ if (typeof status !== "string" || !READER_REDIRECT_STATUSES.includes(status)) {
108
+ fail(`${field}.status`, `must be one of ${READER_REDIRECT_STATUSES.join(", ")}, got ${JSON.stringify(status)}`);
109
+ }
110
+ return {
111
+ source: requireString(record.source, `${field}.source`),
112
+ target: requireString(record.target, `${field}.target`),
113
+ status: status,
114
+ };
115
+ });
116
+ }
117
+ function parseReaderCustomHeaders(value) {
118
+ if (value === undefined)
119
+ return undefined;
120
+ if (!Array.isArray(value) || value.length === 0)
121
+ fail("reader.customHeaders", "must be a non-empty array");
122
+ return value.map((entry, index) => {
123
+ const field = `reader.customHeaders[${index}]`;
124
+ const record = requireObject(entry, field);
125
+ rejectUnknownKeys(record, ["pattern", "headers"], field);
126
+ const headers = requireStringRecord(record.headers, `${field}.headers`);
127
+ if (Object.keys(headers).length === 0)
128
+ fail(`${field}.headers`, "must not be empty");
129
+ return { pattern: requireString(record.pattern, `${field}.pattern`), headers };
130
+ });
131
+ }
132
+ function parseReaderPreBuildCommands(value) {
133
+ if (value === undefined)
134
+ return undefined;
135
+ if (!Array.isArray(value) || value.length === 0)
136
+ fail("reader.preBuildCommands", "must be a non-empty array of commands");
137
+ return value.map((command, index) => requireString(command, `reader.preBuildCommands[${index}]`));
138
+ }
81
139
  export function resolveRedirectSubDomainPrefix(rootDomain, source) {
82
140
  return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
83
141
  }
@@ -203,7 +261,10 @@ export function parseSiteConfig(raw) {
203
261
  if (frontend !== "markus-static")
204
262
  fail("reader", `is only allowed when frontend is "markus-static"`);
205
263
  const readerRecord = requireObject(record.reader, "reader");
206
- rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
264
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "domainPrefixes", "branchName", "buildCommand", "baseDirectory", "environment", "redirects", "customHeaders", "preBuildCommands", "skipContentExport"], "reader");
265
+ if (readerRecord.skipContentExport !== undefined && typeof readerRecord.skipContentExport !== "boolean") {
266
+ fail("reader.skipContentExport", `must be a boolean, got ${JSON.stringify(readerRecord.skipContentExport)}`);
267
+ }
207
268
  reader = {
208
269
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
209
270
  domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
@@ -211,7 +272,14 @@ export function parseSiteConfig(raw) {
211
272
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
212
273
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
213
274
  environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
275
+ domainPrefixes: parseReaderDomainPrefixes(readerRecord.domainPrefixes),
276
+ redirects: parseReaderRedirects(readerRecord.redirects),
277
+ customHeaders: parseReaderCustomHeaders(readerRecord.customHeaders),
278
+ preBuildCommands: parseReaderPreBuildCommands(readerRecord.preBuildCommands),
279
+ skipContentExport: readerRecord.skipContentExport,
214
280
  };
281
+ if (reader.domainPrefixes && !reader.domainName)
282
+ fail("reader.domainPrefixes", "requires reader.domainName");
215
283
  }
216
284
  else if (frontend === "markus-static") {
217
285
  fail("reader", `is required when frontend is "markus-static"`);
@@ -3,6 +3,21 @@ export type CmsRedirect = {
3
3
  status: 301 | 302;
4
4
  };
5
5
 
6
+ export type ReaderRedirectStatus = "301" | "302" | "200" | "404" | "404-200";
7
+
8
+ export type ReaderRedirect = {
9
+ source: string;
10
+ target: string;
11
+ status: ReaderRedirectStatus;
12
+ };
13
+
14
+ export type ReaderCustomHeaderRule = {
15
+ pattern: string;
16
+ headers: Record<string, string>;
17
+ };
18
+
19
+ export const READER_REDIRECT_STATUSES: ReaderRedirectStatus[] = ["301", "302", "200", "404", "404-200"];
20
+
6
21
  export type AmplifyAppShellSiteConfig = {
7
22
  siteId: string;
8
23
  repository: string;
@@ -26,10 +41,15 @@ export type AmplifyAppShellSiteConfig = {
26
41
  reader?: {
27
42
  appName?: string;
28
43
  domainName?: string;
44
+ domainPrefixes?: string[];
29
45
  branchName?: string;
30
46
  buildCommand: string;
31
47
  baseDirectory: string;
32
48
  environment?: Record<string, string>;
49
+ redirects?: ReaderRedirect[];
50
+ customHeaders?: ReaderCustomHeaderRule[];
51
+ preBuildCommands?: string[];
52
+ skipContentExport?: boolean;
33
53
  };
34
54
  papyrusVersion: string;
35
55
  storagePreviewPrefix?: string;
@@ -126,6 +146,56 @@ function parseRedirects(value: unknown): CmsRedirect[] | undefined {
126
146
  });
127
147
  }
128
148
 
149
+ function parseReaderDomainPrefixes(value: unknown): string[] | undefined {
150
+ if (value === undefined) return undefined;
151
+ if (!Array.isArray(value) || value.length === 0) fail("reader.domainPrefixes", "must be a non-empty array");
152
+ const prefixes = (value as unknown[]).map((entry, index) => {
153
+ const field = `reader.domainPrefixes[${index}]`;
154
+ if (entry === "") return "";
155
+ return requireString(entry, field, DOMAIN_PREFIX_PATTERN, 'single DNS label, or "" for the apex');
156
+ });
157
+ if (new Set(prefixes).size !== prefixes.length) fail("reader.domainPrefixes", "must not repeat a prefix");
158
+ return prefixes;
159
+ }
160
+
161
+ function parseReaderRedirects(value: unknown): ReaderRedirect[] | undefined {
162
+ if (value === undefined) return undefined;
163
+ if (!Array.isArray(value) || value.length === 0) fail("reader.redirects", "must be a non-empty array");
164
+ return (value as unknown[]).map((entry, index) => {
165
+ const field = `reader.redirects[${index}]`;
166
+ const record = requireObject(entry, field);
167
+ rejectUnknownKeys(record, ["source", "target", "status"], field);
168
+ const status = record.status ?? "301";
169
+ if (typeof status !== "string" || !READER_REDIRECT_STATUSES.includes(status as ReaderRedirectStatus)) {
170
+ fail(`${field}.status`, `must be one of ${READER_REDIRECT_STATUSES.join(", ")}, got ${JSON.stringify(status)}`);
171
+ }
172
+ return {
173
+ source: requireString(record.source, `${field}.source`),
174
+ target: requireString(record.target, `${field}.target`),
175
+ status: status as ReaderRedirectStatus,
176
+ };
177
+ });
178
+ }
179
+
180
+ function parseReaderCustomHeaders(value: unknown): ReaderCustomHeaderRule[] | undefined {
181
+ if (value === undefined) return undefined;
182
+ if (!Array.isArray(value) || value.length === 0) fail("reader.customHeaders", "must be a non-empty array");
183
+ return (value as unknown[]).map((entry, index) => {
184
+ const field = `reader.customHeaders[${index}]`;
185
+ const record = requireObject(entry, field);
186
+ rejectUnknownKeys(record, ["pattern", "headers"], field);
187
+ const headers = requireStringRecord(record.headers, `${field}.headers`);
188
+ if (Object.keys(headers).length === 0) fail(`${field}.headers`, "must not be empty");
189
+ return { pattern: requireString(record.pattern, `${field}.pattern`), headers };
190
+ });
191
+ }
192
+
193
+ function parseReaderPreBuildCommands(value: unknown): string[] | undefined {
194
+ if (value === undefined) return undefined;
195
+ if (!Array.isArray(value) || value.length === 0) fail("reader.preBuildCommands", "must be a non-empty array of commands");
196
+ return (value as unknown[]).map((command, index) => requireString(command, `reader.preBuildCommands[${index}]`));
197
+ }
198
+
129
199
  export function resolveRedirectSubDomainPrefix(rootDomain: string, source: string): string {
130
200
  return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
131
201
  }
@@ -264,7 +334,10 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
264
334
  if (record.reader !== undefined) {
265
335
  if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
266
336
  const readerRecord = requireObject(record.reader, "reader");
267
- rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
337
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "domainPrefixes", "branchName", "buildCommand", "baseDirectory", "environment", "redirects", "customHeaders", "preBuildCommands", "skipContentExport"], "reader");
338
+ if (readerRecord.skipContentExport !== undefined && typeof readerRecord.skipContentExport !== "boolean") {
339
+ fail("reader.skipContentExport", `must be a boolean, got ${JSON.stringify(readerRecord.skipContentExport)}`);
340
+ }
268
341
  reader = {
269
342
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
270
343
  domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
@@ -272,7 +345,13 @@ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
272
345
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
273
346
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
274
347
  environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
348
+ domainPrefixes: parseReaderDomainPrefixes(readerRecord.domainPrefixes),
349
+ redirects: parseReaderRedirects(readerRecord.redirects),
350
+ customHeaders: parseReaderCustomHeaders(readerRecord.customHeaders),
351
+ preBuildCommands: parseReaderPreBuildCommands(readerRecord.preBuildCommands),
352
+ skipContentExport: readerRecord.skipContentExport as boolean | undefined,
275
353
  };
354
+ if (reader.domainPrefixes && !reader.domainName) fail("reader.domainPrefixes", "requires reader.domainName");
276
355
  } else if (frontend === "markus-static") {
277
356
  fail("reader", `is required when frontend is "markus-static"`);
278
357
  }
package/package.json CHANGED
@@ -302,7 +302,7 @@
302
302
  "optional": true
303
303
  }
304
304
  },
305
- "version": "1.0.0-next.40",
305
+ "version": "1.0.0-next.41",
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.40",
2
+ "version": "1.0.0-next.41",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",