@anthusai/papyrus 1.0.0-next.3 → 1.0.0-next.5

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 (40) hide show
  1. package/amplify/storage/resource.js +3 -0
  2. package/amplify/storage/resource.ts +3 -0
  3. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  4. package/app/globals.css +19 -0
  5. package/app/layout.tsx +4 -0
  6. package/app/robots.ts +11 -0
  7. package/bin/papyrus-app.mjs +12 -2
  8. package/bin/papyrus-infra.mjs +72 -16
  9. package/components/article-video.tsx +112 -3
  10. package/components/staging-banner.tsx +11 -0
  11. package/infra/amplify-app-shell.js +178 -80
  12. package/infra/amplify-app-shell.ts +217 -113
  13. package/infra/build-specs.js +86 -0
  14. package/infra/build-specs.ts +132 -0
  15. package/infra/github-oidc-provider.js +24 -0
  16. package/infra/github-oidc-provider.ts +30 -0
  17. package/infra/index.js +3 -0
  18. package/infra/site-config.js +155 -0
  19. package/infra/site-config.ts +200 -0
  20. package/lib/cached-content-repository.ts +2 -1
  21. package/lib/content-repository.ts +3 -0
  22. package/lib/content-source-context.ts +16 -0
  23. package/lib/graphql-content-repository.ts +182 -37
  24. package/lib/site-brand.ts +13 -0
  25. package/lib/site-env.ts +52 -0
  26. package/lib/staging-gate.ts +37 -0
  27. package/lib/staging-preview-object.ts +98 -0
  28. package/lib/video-mode.ts +42 -0
  29. package/lib/video-script.ts +63 -0
  30. package/middleware.ts +51 -1
  31. package/package.json +11 -1
  32. package/routes.manifest.json +11 -1
  33. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  34. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  35. package/scripts/videoml/bundle-common.mjs +129 -0
  36. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  37. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  38. package/scripts/videoml/preview/preview-timing.ts +42 -0
  39. package/scripts/videoml/preview.html +77 -0
  40. package/scripts/videoml/standard-entry.tsx +46 -0
@@ -0,0 +1,155 @@
1
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
2
+ const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
3
+ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
4
+ const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
5
+ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
6
+ const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
7
+ const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
8
+ const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
9
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
10
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
11
+ const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
12
+ function fail(field, problem) {
13
+ throw new Error(`site.json: ${field} ${problem}`);
14
+ }
15
+ function requireObject(value, field) {
16
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
17
+ fail(field, "must be an object");
18
+ }
19
+ return value;
20
+ }
21
+ function requireString(value, field, pattern, patternHint) {
22
+ if (typeof value !== "string" || value.trim() === "") {
23
+ fail(field, "must be a non-empty string");
24
+ }
25
+ if (pattern && !pattern.test(value)) {
26
+ fail(field, `is invalid${patternHint ? ` (${patternHint})` : ""}: ${JSON.stringify(value)}`);
27
+ }
28
+ return value;
29
+ }
30
+ function optionalString(value, field, pattern, patternHint) {
31
+ return value === undefined ? undefined : requireString(value, field, pattern, patternHint);
32
+ }
33
+ function requireStringRecord(value, field) {
34
+ const record = requireObject(value, field);
35
+ const result = {};
36
+ for (const [key, entry] of Object.entries(record)) {
37
+ if (typeof entry !== "string")
38
+ fail(`${field}.${key}`, "must be a string");
39
+ result[key] = entry;
40
+ }
41
+ return result;
42
+ }
43
+ function rejectUnknownKeys(record, allowed, field) {
44
+ for (const key of Object.keys(record)) {
45
+ if (!allowed.includes(key))
46
+ fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
47
+ }
48
+ }
49
+ export function resolveStagingDomainName(config) {
50
+ if (config.cms.stagingDomainName)
51
+ return config.cms.stagingDomainName;
52
+ const [, ...rest] = config.cms.domainName.split(".");
53
+ return ["staging", ...rest].join(".");
54
+ }
55
+ export function resolveStoragePreviewPrefix(config) {
56
+ return config.storagePreviewPrefix ?? "preview/";
57
+ }
58
+ export function parseSiteConfig(raw) {
59
+ const record = requireObject(raw, "site");
60
+ rejectUnknownKeys(record, ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"], "site");
61
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
62
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
63
+ const brand = requireString(record.brand, "brand");
64
+ const frontend = requireString(record.frontend, "frontend");
65
+ if (frontend !== "pretext" && frontend !== "markus-static") {
66
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
67
+ }
68
+ const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
69
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
70
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
71
+ const cmsRecord = requireObject(record.cms, "cms");
72
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
73
+ const buildComputeType = cmsRecord.buildComputeType;
74
+ if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
75
+ fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
76
+ }
77
+ const cms = {
78
+ appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
79
+ domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
80
+ buildComputeType: buildComputeType,
81
+ environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
82
+ stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
83
+ };
84
+ let reader;
85
+ if (record.reader !== undefined) {
86
+ if (frontend !== "markus-static")
87
+ fail("reader", `is only allowed when frontend is "markus-static"`);
88
+ const readerRecord = requireObject(record.reader, "reader");
89
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
90
+ reader = {
91
+ appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
92
+ domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
93
+ branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
94
+ buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
95
+ baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
96
+ environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
97
+ };
98
+ }
99
+ else if (frontend === "markus-static") {
100
+ fail("reader", `is required when frontend is "markus-static"`);
101
+ }
102
+ let github;
103
+ if (record.github !== undefined) {
104
+ const githubRecord = requireObject(record.github, "github");
105
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
106
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
107
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
108
+ if (repository !== `https://github.com/${owner}/${repo}`) {
109
+ fail("github", `owner/repo must match repository ${repository}`);
110
+ }
111
+ let branches = DEFAULT_CI_BRANCHES;
112
+ if (githubRecord.branches !== undefined) {
113
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
114
+ fail("github.branches", "must be a non-empty array of branch names");
115
+ }
116
+ branches = githubRecord.branches.map((branch, index) => {
117
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
118
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
119
+ }
120
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
121
+ });
122
+ }
123
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
124
+ if (typeof ciCanDeployInfra !== "boolean")
125
+ fail("github.ciCanDeployInfra", "must be a boolean");
126
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra };
127
+ }
128
+ const config = {
129
+ siteId,
130
+ repository,
131
+ brand,
132
+ frontend,
133
+ hostedZoneId,
134
+ cms,
135
+ reader,
136
+ papyrusVersion,
137
+ storagePreviewPrefix,
138
+ github,
139
+ };
140
+ const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
141
+ if (redirectValue === undefined)
142
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
143
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
144
+ const requiredOrigins = [
145
+ `https://${cms.domainName}/`,
146
+ `https://${resolveStagingDomainName(config)}/`,
147
+ LOCAL_DEVELOPMENT_ORIGIN,
148
+ ];
149
+ for (const origin of requiredOrigins) {
150
+ if (!redirectUrls.includes(origin)) {
151
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
152
+ }
153
+ }
154
+ return config;
155
+ }
@@ -0,0 +1,200 @@
1
+ export type AmplifyAppShellSiteConfig = {
2
+ siteId: string;
3
+ repository: string;
4
+ brand: string;
5
+ frontend: "pretext" | "markus-static";
6
+ hostedZoneId: string;
7
+ cms: {
8
+ appName?: string;
9
+ domainName: string;
10
+ buildComputeType?: "STANDARD" | "STANDARD_8GB";
11
+ environment: Record<string, string>;
12
+ stagingDomainName?: string;
13
+ };
14
+ reader?: {
15
+ appName?: string;
16
+ domainName: string;
17
+ branchName?: string;
18
+ buildCommand: string;
19
+ baseDirectory: string;
20
+ environment?: Record<string, string>;
21
+ };
22
+ papyrusVersion: string;
23
+ storagePreviewPrefix?: string;
24
+ github?: {
25
+ owner: string;
26
+ repo: string;
27
+ branches: string[];
28
+ ciCanDeployInfra: boolean;
29
+ };
30
+ };
31
+
32
+ export const DEFAULT_CI_BRANCHES = ["main", "staging"];
33
+
34
+ const SITE_ID_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
35
+ const REPOSITORY_PATTERN = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
36
+ const HOSTED_ZONE_ID_PATTERN = /^Z[A-Z0-9]+$/;
37
+ const HOST_NAME_PATTERN = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
38
+ const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 _.-]*$/;
39
+ const BRANCH_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9/_.-]*$/;
40
+ const PAPYRUS_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+\.\d+)?$/;
41
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
42
+ const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+$/;
43
+ const LOCAL_DEVELOPMENT_ORIGIN = "http://localhost:3001/";
44
+
45
+ function fail(field: string, problem: string): never {
46
+ throw new Error(`site.json: ${field} ${problem}`);
47
+ }
48
+
49
+ function requireObject(value: unknown, field: string): Record<string, unknown> {
50
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
51
+ fail(field, "must be an object");
52
+ }
53
+ return value as Record<string, unknown>;
54
+ }
55
+
56
+ function requireString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string {
57
+ if (typeof value !== "string" || value.trim() === "") {
58
+ fail(field, "must be a non-empty string");
59
+ }
60
+ if (pattern && !pattern.test(value as string)) {
61
+ fail(field, `is invalid${patternHint ? ` (${patternHint})` : ""}: ${JSON.stringify(value)}`);
62
+ }
63
+ return value as string;
64
+ }
65
+
66
+ function optionalString(value: unknown, field: string, pattern?: RegExp, patternHint?: string): string | undefined {
67
+ return value === undefined ? undefined : requireString(value, field, pattern, patternHint);
68
+ }
69
+
70
+ function requireStringRecord(value: unknown, field: string): Record<string, string> {
71
+ const record = requireObject(value, field);
72
+ const result: Record<string, string> = {};
73
+ for (const [key, entry] of Object.entries(record)) {
74
+ if (typeof entry !== "string") fail(`${field}.${key}`, "must be a string");
75
+ result[key] = entry as string;
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function rejectUnknownKeys(record: Record<string, unknown>, allowed: string[], field: string): void {
81
+ for (const key of Object.keys(record)) {
82
+ if (!allowed.includes(key)) fail(`${field}.${key}`, `is not a known field (known: ${allowed.join(", ")})`);
83
+ }
84
+ }
85
+
86
+ export function resolveStagingDomainName(config: Pick<AmplifyAppShellSiteConfig, "cms">): string {
87
+ if (config.cms.stagingDomainName) return config.cms.stagingDomainName;
88
+ const [, ...rest] = config.cms.domainName.split(".");
89
+ return ["staging", ...rest].join(".");
90
+ }
91
+
92
+ export function resolveStoragePreviewPrefix(config: Pick<AmplifyAppShellSiteConfig, "storagePreviewPrefix">): string {
93
+ return config.storagePreviewPrefix ?? "preview/";
94
+ }
95
+
96
+ export function parseSiteConfig(raw: unknown): AmplifyAppShellSiteConfig {
97
+ const record = requireObject(raw, "site");
98
+ rejectUnknownKeys(
99
+ record,
100
+ ["siteId", "repository", "brand", "frontend", "hostedZoneId", "cms", "reader", "papyrusVersion", "storagePreviewPrefix", "github"],
101
+ "site",
102
+ );
103
+
104
+ const siteId = requireString(record.siteId, "siteId", SITE_ID_PATTERN, "kebab-case");
105
+ const repository = requireString(record.repository, "repository", REPOSITORY_PATTERN, "https://github.com/<org>/<repo>");
106
+ const brand = requireString(record.brand, "brand");
107
+ const frontend = requireString(record.frontend, "frontend");
108
+ if (frontend !== "pretext" && frontend !== "markus-static") {
109
+ fail("frontend", `must be "pretext" or "markus-static", got ${JSON.stringify(frontend)}`);
110
+ }
111
+ const hostedZoneId = requireString(record.hostedZoneId, "hostedZoneId", HOSTED_ZONE_ID_PATTERN, "Route 53 zone id");
112
+ const papyrusVersion = requireString(record.papyrusVersion, "papyrusVersion", PAPYRUS_VERSION_PATTERN, "exact version such as 1.0.0 or 1.0.0-next.1");
113
+ const storagePreviewPrefix = optionalString(record.storagePreviewPrefix, "storagePreviewPrefix", /^[A-Za-z0-9._-]+\/$/, "must end with /");
114
+
115
+ const cmsRecord = requireObject(record.cms, "cms");
116
+ rejectUnknownKeys(cmsRecord, ["appName", "domainName", "buildComputeType", "environment", "stagingDomainName"], "cms");
117
+ const buildComputeType = cmsRecord.buildComputeType;
118
+ if (buildComputeType !== undefined && buildComputeType !== "STANDARD" && buildComputeType !== "STANDARD_8GB") {
119
+ fail("cms.buildComputeType", `must be "STANDARD" or "STANDARD_8GB", got ${JSON.stringify(buildComputeType)}`);
120
+ }
121
+ const cms: AmplifyAppShellSiteConfig["cms"] = {
122
+ appName: optionalString(cmsRecord.appName, "cms.appName", APP_NAME_PATTERN),
123
+ domainName: requireString(cmsRecord.domainName, "cms.domainName", HOST_NAME_PATTERN, "host name"),
124
+ buildComputeType: buildComputeType as "STANDARD" | "STANDARD_8GB" | undefined,
125
+ environment: requireStringRecord(cmsRecord.environment, "cms.environment"),
126
+ stagingDomainName: optionalString(cmsRecord.stagingDomainName, "cms.stagingDomainName", HOST_NAME_PATTERN, "host name"),
127
+ };
128
+
129
+ let reader: AmplifyAppShellSiteConfig["reader"];
130
+ if (record.reader !== undefined) {
131
+ if (frontend !== "markus-static") fail("reader", `is only allowed when frontend is "markus-static"`);
132
+ const readerRecord = requireObject(record.reader, "reader");
133
+ rejectUnknownKeys(readerRecord, ["appName", "domainName", "branchName", "buildCommand", "baseDirectory", "environment"], "reader");
134
+ reader = {
135
+ appName: optionalString(readerRecord.appName, "reader.appName", APP_NAME_PATTERN),
136
+ domainName: requireString(readerRecord.domainName, "reader.domainName", HOST_NAME_PATTERN, "host name"),
137
+ branchName: optionalString(readerRecord.branchName, "reader.branchName", BRANCH_NAME_PATTERN),
138
+ buildCommand: requireString(readerRecord.buildCommand, "reader.buildCommand"),
139
+ baseDirectory: requireString(readerRecord.baseDirectory, "reader.baseDirectory"),
140
+ environment: readerRecord.environment === undefined ? undefined : requireStringRecord(readerRecord.environment, "reader.environment"),
141
+ };
142
+ } else if (frontend === "markus-static") {
143
+ fail("reader", `is required when frontend is "markus-static"`);
144
+ }
145
+
146
+ let github: AmplifyAppShellSiteConfig["github"];
147
+ if (record.github !== undefined) {
148
+ const githubRecord = requireObject(record.github, "github");
149
+ rejectUnknownKeys(githubRecord, ["owner", "repo", "branches", "ciCanDeployInfra"], "github");
150
+ const owner = requireString(githubRecord.owner, "github.owner", GITHUB_OWNER_PATTERN, "plain GitHub login");
151
+ const repo = requireString(githubRecord.repo, "github.repo", GITHUB_REPO_PATTERN);
152
+ if (repository !== `https://github.com/${owner}/${repo}`) {
153
+ fail("github", `owner/repo must match repository ${repository}`);
154
+ }
155
+ let branches = DEFAULT_CI_BRANCHES;
156
+ if (githubRecord.branches !== undefined) {
157
+ if (!Array.isArray(githubRecord.branches) || githubRecord.branches.length === 0) {
158
+ fail("github.branches", "must be a non-empty array of branch names");
159
+ }
160
+ branches = (githubRecord.branches as unknown[]).map((branch, index) => {
161
+ if (typeof branch === "string" && /[*?]/.test(branch)) {
162
+ fail(`github.branches[${index}]`, `must not contain wildcards: ${JSON.stringify(branch)}`);
163
+ }
164
+ return requireString(branch, `github.branches[${index}]`, BRANCH_NAME_PATTERN);
165
+ });
166
+ }
167
+ const ciCanDeployInfra = githubRecord.ciCanDeployInfra ?? false;
168
+ if (typeof ciCanDeployInfra !== "boolean") fail("github.ciCanDeployInfra", "must be a boolean");
169
+ github = { owner, repo, branches, ciCanDeployInfra: ciCanDeployInfra as boolean };
170
+ }
171
+
172
+ const config: AmplifyAppShellSiteConfig = {
173
+ siteId,
174
+ repository,
175
+ brand,
176
+ frontend,
177
+ hostedZoneId,
178
+ cms,
179
+ reader,
180
+ papyrusVersion,
181
+ storagePreviewPrefix,
182
+ github,
183
+ };
184
+
185
+ const redirectValue = cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS;
186
+ if (redirectValue === undefined) fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", "is required");
187
+ const redirectUrls = redirectValue.split(",").map((url) => url.trim());
188
+ const requiredOrigins = [
189
+ `https://${cms.domainName}/`,
190
+ `https://${resolveStagingDomainName(config)}/`,
191
+ LOCAL_DEVELOPMENT_ORIGIN,
192
+ ];
193
+ for (const origin of requiredOrigins) {
194
+ if (!redirectUrls.includes(origin)) {
195
+ fail("cms.environment.PAPYRUS_OAUTH_REDIRECT_URLS", `must include ${origin}`);
196
+ }
197
+ }
198
+
199
+ return config;
200
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  } from "./content-types";
11
11
  import { graphqlContentRepository } from "./graphql-content-repository";
12
12
  import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
13
+ import { getContentSource } from "./site-env";
13
14
 
14
15
  const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
15
16
 
@@ -34,7 +35,7 @@ function withReaderCache<T>(
34
35
  tags: string[],
35
36
  loader: () => Promise<T>,
36
37
  ): Promise<T> {
37
- if (bypassReaderCacheInDevelopment) return loader();
38
+ if (bypassReaderCacheInDevelopment || getContentSource() === "drafts") return loader();
38
39
  return unstable_cache(loader, cacheKey, {
39
40
  tags,
40
41
  revalidate: READER_REVALIDATE_SECONDS,
@@ -1,6 +1,9 @@
1
1
  import type { ContentRepository } from "./content-types";
2
2
  import { graphqlContentRepository } from "./graphql-content-repository";
3
3
  import { getLayoutScenario } from "./layout-scenarios";
4
+ import { assertContentSourceMatchesEnv } from "./site-env";
5
+
6
+ assertContentSourceMatchesEnv();
4
7
 
5
8
  export const contentRepository: ContentRepository = {
6
9
  loadEditionContent({ scenarioId, editionDate, editionSlug } = {}) {
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { getContentSource, type ContentSource } from "./site-env";
3
+
4
+ export type ContentSourceContext = {
5
+ source: ContentSource;
6
+ };
7
+
8
+ const contentSourceStorage = new AsyncLocalStorage<ContentSourceContext>();
9
+
10
+ export function runWithContentSource<T>(source: ContentSource, operation: () => Promise<T>): Promise<T> {
11
+ return contentSourceStorage.run({ source }, operation);
12
+ }
13
+
14
+ export function currentContentSource(): ContentSource {
15
+ return contentSourceStorage.getStore()?.source ?? getContentSource();
16
+ }