@anthusai/papyrus 1.0.0-next.4 → 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.
Files changed (147) 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 +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  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 +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +236 -11
  82. package/infra/site-config.ts +276 -15
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -1,10 +1,30 @@
1
+ export const READER_REDIRECT_STATUSES = ["301", "302", "200", "404", "404-200"];
2
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
1
3
  const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
2
4
  const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
3
5
  const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
4
6
  const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
7
+ const DOMAIN_PREFIX_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5
8
  const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
9
+ const STACK_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,127}$/;
6
10
  const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
7
11
  const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
12
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
13
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
14
+ const COGNITO_DOMAIN_PREFIX_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
15
+ const COGNITO_RESERVED_WORDS = ["aws", "amazon", "cognito"];
16
+ const TEMPLATE_MANAGED_ENVIRONMENT_KEYS = ["PAPYRUS_COGNITO_DOMAIN_PREFIX", "PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX", "PAPYRUS_DISABLE_GOOGLE_OAUTH", "PAPYRUS_REVALIDATE_SECRET_PARAMETER"];
17
+ function originOfRedirectUrl(value) {
18
+ try {
19
+ return new URL(value).origin;
20
+ }
21
+ catch {
22
+ return value;
23
+ }
24
+ }
25
+ export function resolveRevalidateSecretParameterName(config) {
26
+ return `/papyrus/${config.siteId}/revalidate-secret`;
27
+ }
8
28
  const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
9
29
  function fail(field, problem) {
10
30
  throw new Error(`site.json: ${field} ${problem}`);
@@ -43,18 +63,112 @@ function rejectUnknownKeys(record, allowed, field) {
43
63
  fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
44
64
  }
45
65
  }
66
+ function parseRedirects(value) {
67
+ if (value === undefined)
68
+ return undefined;
69
+ if (!Array.isArray(value) || value.length === 0)
70
+ fail("cms.redirects", "must be a non-empty array");
71
+ return value.map((entry, index) => {
72
+ const field = `cms.redirects[${index}]`;
73
+ const record = requireObject(entry, field);
74
+ rejectUnknownKeys(record, ["source", "status"], field);
75
+ const source = requireString(record.source, `${field}.source`, HOST_NAME_PATTERN, "host name without scheme or path");
76
+ const status = record.status ?? 301;
77
+ if (status !== 301 && status !== 302)
78
+ fail(`${field}.status`, `must be 301 or 302, got ${JSON.stringify(status)}`);
79
+ return { source, status };
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
+ }
139
+ export function resolveRedirectSubDomainPrefix(rootDomain, source) {
140
+ return source === rootDomain ? "" : source.slice(0, -rootDomain.length - 1);
141
+ }
142
+ export function isStagingEnabled(config) {
143
+ return config.cms.staging !== false;
144
+ }
145
+ export function resolveCmsHostName(config) {
146
+ if (!config.cms.domainName)
147
+ return undefined;
148
+ return config.cms.domainPrefix ? `${config.cms.domainPrefix}.${config.cms.domainName}` : config.cms.domainName;
149
+ }
46
150
  export function resolveStagingDomainName(config) {
151
+ if (!isStagingEnabled(config))
152
+ return undefined;
153
+ if (config.cms.domainPrefix) {
154
+ return config.cms.stagingDomainPrefix ? `${config.cms.stagingDomainPrefix}.${config.cms.domainName}` : undefined;
155
+ }
47
156
  if (config.cms.stagingDomainName)
48
157
  return config.cms.stagingDomainName;
158
+ if (!config.cms.domainName)
159
+ return undefined;
49
160
  const [, ...rest] = config.cms.domainName.split(".");
50
161
  return ["staging", ...rest].join(".");
51
162
  }
163
+ export function resolveStackName(config) {
164
+ return config.stackName ?? `amplify-app-shell-${config.siteId}`;
165
+ }
52
166
  export function resolveStoragePreviewPrefix(config) {
53
167
  return config.storagePreviewPrefix ?? "preview/";
54
168
  }
55
169
  export function parseSiteConfig(raw) {
56
170
  const record = requireObject(raw, "site");
57
- rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix"], "site");
171
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "stackName", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
58
172
  const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
59
173
  const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
60
174
  const brand = requireString(record.brand, "brand");
@@ -62,63 +176,174 @@ export function parseSiteConfig(raw) {
62
176
  if (frontend !== "pretext" && frontend !== "markus-static") {
63
177
  fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
64
178
  }
65
- const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
179
+ const hostedZoneId = optionalString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
180
+ const stackName = optionalString(record.stackName, "stackName", STACK_NAME_PATTERN, "CloudFormation stack name");
66
181
  const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
67
182
  const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
68
183
  const cmsRecord = requireObject(record.cms, "cms");
69
- rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
184
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "staging", "cognitoDomainPrefix", "applyCognitoDomainPrefix", "buildComputeType", "environment", "stagingDomainName", "domainPrefix", "stagingDomainPrefix", "redirects"], "cms");
70
185
  const buildComputeType = cmsRecord.buildComputeType;
71
186
  if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
72
187
  fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
73
188
  }
189
+ if (cmsRecord.staging !== undefined && typeof cmsRecord.staging !== "boolean") {
190
+ fail("cms.staging", `must be a boolean, got ${JSON.stringify(cmsRecord.staging)}`);
191
+ }
192
+ if (cmsRecord.applyCognitoDomainPrefix !== undefined && typeof cmsRecord.applyCognitoDomainPrefix !== "boolean") {
193
+ fail("cms.applyCognitoDomainPrefix", `must be a boolean, got ${JSON.stringify(cmsRecord.applyCognitoDomainPrefix)}`);
194
+ }
74
195
  const cms = {
75
196
  appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
76
- domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
197
+ domainName: optionalString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
198
+ domainPrefix: optionalString(cmsRecord.domainPrefix, "cms.domainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
199
+ staging: cmsRecord.staging,
200
+ cognitoDomainPrefix: requireString(cmsRecord.cognitoDomainPrefix, "cms.cognitoDomainPrefix", COGNITO_DOMAIN_PREFIX_PATTERN, "lowercase letters, digits and hyphens; globally unique per region"),
201
+ applyCognitoDomainPrefix: cmsRecord.applyCognitoDomainPrefix,
77
202
  buildComputeType: buildComputeType,
78
203
  environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
79
204
  stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
205
+ stagingDomainPrefix: optionalString(cmsRecord.stagingDomainPrefix, "cms.stagingDomainPrefix", DOMAIN_PREFIX_PATTERN, "single DNS label"),
206
+ redirects: parseRedirects(cmsRecord.redirects),
80
207
  };
208
+ const reservedWord = COGNITO_RESERVED_WORDS.find((word) => cms.cognitoDomainPrefix.includes(word));
209
+ if (reservedWord) {
210
+ fail("cms.cognitoDomainPrefix", `must not contain "${reservedWord}" (Cognito reserves it): ${JSON.stringify(cms.cognitoDomainPrefix)}`);
211
+ }
212
+ for (const key of TEMPLATE_MANAGED_ENVIRONMENT_KEYS) {
213
+ if (key in cms.environment) {
214
+ fail(`cms.environment.${key}`, "is managed by the template: Google sign-in is always on, the Cognito domain comes from cms.cognitoDomainPrefix and the revalidation secret parameter is derived from siteId");
215
+ }
216
+ }
217
+ if (cms.staging === false && cms.stagingDomainName) {
218
+ fail("cms.stagingDomainName", "must not be set when cms.staging is false");
219
+ }
220
+ if (cms.stagingDomainName && !cms.domainName) {
221
+ fail("cms.stagingDomainName", "requires cms.domainName");
222
+ }
223
+ if (cms.domainPrefix && !cms.domainName) {
224
+ fail("cms.domainPrefix", "requires cms.domainName (the root domain)");
225
+ }
226
+ if (cms.stagingDomainPrefix && !cms.domainPrefix) {
227
+ fail("cms.stagingDomainPrefix", "requires cms.domainPrefix");
228
+ }
229
+ if (cms.domainPrefix && cms.stagingDomainName) {
230
+ fail("cms.stagingDomainName", "must not be set with cms.domainPrefix: use cms.stagingDomainPrefix");
231
+ }
232
+ if (cms.staging === false && cms.stagingDomainPrefix) {
233
+ fail("cms.stagingDomainPrefix", "must not be set when cms.staging is false");
234
+ }
235
+ if (cms.domainPrefix && cms.staging !== false && !cms.stagingDomainPrefix) {
236
+ fail("cms.stagingDomainPrefix", "is required when cms.domainPrefix is set and staging is enabled: the staging host is never derived under a shared root domain");
237
+ }
238
+ if (cms.domainPrefix && cms.stagingDomainPrefix === cms.domainPrefix) {
239
+ fail("cms.stagingDomainPrefix", "must differ from cms.domainPrefix");
240
+ }
241
+ if (cms.redirects) {
242
+ if (!cms.domainPrefix)
243
+ fail("cms.redirects", "requires cms.domainPrefix (redirects target the primary host under a shared root domain)");
244
+ cms.redirects.forEach((redirect, index) => {
245
+ const field = `cms.redirects[${index}].source`;
246
+ const root = cms.domainName;
247
+ if (redirect.source !== root && !(redirect.source.endsWith(`.${root}`) && DOMAIN_PREFIX_PATTERN.test(redirect.source.slice(0, -root.length - 1)))) {
248
+ fail(field, `must be the root domain ${root} or a single label under it: ${JSON.stringify(redirect.source)}`);
249
+ }
250
+ if (redirect.source === resolveCmsHostName({ cms }))
251
+ fail(field, "must differ from the primary host");
252
+ if (redirect.source === resolveStagingDomainName({ cms }))
253
+ fail(field, "must differ from the staging host");
254
+ });
255
+ if (new Set(cms.redirects.map((redirect) => redirect.source)).size !== cms.redirects.length) {
256
+ fail("cms.redirects", "must not repeat a source");
257
+ }
258
+ }
81
259
  let reader;
82
260
  if (record.reader !== undefined) {
83
261
  if (frontend !== "markus-static")
84
262
  fail("reader", `is only allowed when frontend is "markus-static"`);
85
263
  const readerRecord = requireObject(record.reader, "reader");
86
- 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
+ }
87
268
  reader = {
88
269
  appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
89
- domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
270
+ domainName: optionalString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
90
271
  branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
91
272
  buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
92
273
  baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
93
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,
94
280
  };
281
+ if (reader.domainPrefixes && !reader.domainName)
282
+ fail("reader.domainPrefixes", "requires reader.domainName");
95
283
  }
96
284
  else if (frontend === "markus-static") {
97
285
  fail("reader", `is required when frontend is "markus-static"`);
98
286
  }
287
+ let github;
288
+ if (record.github !== undefined) {
289
+ const githubRecord = requireObject(record.github, "github");
290
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
291
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
292
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
293
+ if (repository !== `https://github.com/${owner}/${repo}`) {
294
+ fail("github", `owner/repo must match repository ${repository}`);
295
+ }
296
+ let branches = cms.staging === false ? DEFAULT_CI_BRANCHES.filter((branch) => branch !== "staging") : DEFAULT_CI_BRANCHES;
297
+ if (githubRecord.branches !== undefined) {
298
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
299
+ fail("github.branches", "must be a non-empty array of branch names");
300
+ }
301
+ branches = githubRecord.branches.map((branch, index) => {
302
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
303
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
304
+ }
305
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
306
+ });
307
+ }
308
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
309
+ if (typeof ciCanDeployInfra !== "boolean")
310
+ fail("github.ciCanDeployInfra", "must be a boolean");
311
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
312
+ }
313
+ const hasDomain = cms.domainName !== undefined || reader?.domainName !== undefined;
314
+ if (hasDomain && hostedZoneId === undefined) {
315
+ fail("hostedZoneId", "is required when cms.domainName or reader.domainName is set");
316
+ }
317
+ if (!hasDomain && hostedZoneId !== undefined) {
318
+ fail("hostedZoneId", "is only allowed when cms.domainName or reader.domainName is set");
319
+ }
99
320
  const config = {
100
321
  siteId,
101
322
  repository,
102
323
  brand,
103
324
  frontend,
104
325
  hostedZoneId,
326
+ stackName,
105
327
  cms,
106
328
  reader,
107
329
  papyrusVersion,
108
330
  storagePreviewPrefix,
331
+ github,
109
332
  };
110
333
  const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
111
334
  if (redirectValue === undefined)
112
335
  fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
113
336
  const redirectUrls = redirectValue.split(",").map((url) => url.trim());
337
+ const stagingDomainName = resolveStagingDomainName(config);
338
+ const listedOrigins = new Set(redirectUrls.map(originOfRedirectUrl));
114
339
  const requiredOrigins = [
115
- `https://${cms.domainName}/`,
116
- `https://${resolveStagingDomainName(config)}/`,
117
- LOCAL_DEVELOPMENT_ORIGIN,
340
+ ...(resolveCmsHostName(config) ? [`https://${resolveCmsHostName(config)}`] : []),
341
+ ...(stagingDomainName ? [`https://${stagingDomainName}`] : []),
342
+ originOfRedirectUrl(LOCAL_DEVELOPMENT_ORIGIN),
118
343
  ];
119
344
  for (const origin of requiredOrigins) {
120
- if (!redirectUrls.includes(origin)) {
121
- fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
345
+ if (!listedOrigins.has(origin)) {
346
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}/ (or just the origin: the backend also registers its newsroom path)`);
122
347
  }
123
348
  }
124
349
  return config;