@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.6

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 (51) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +44 -2
  6. package/amplify/rebuild-trigger-settings.js +22 -0
  7. package/amplify/rebuild-trigger-settings.ts +42 -0
  8. package/amplify/site-backend-config.ts +6 -0
  9. package/amplify/site-backend.js +18 -0
  10. package/amplify/site-backend.ts +20 -0
  11. package/amplify/storage/resource.js +3 -0
  12. package/amplify/storage/resource.ts +3 -0
  13. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  14. package/app/globals.css +19 -0
  15. package/app/newsroom/articles/[id]/page.tsx +15 -0
  16. package/app/newsroom/articles/page.tsx +13 -0
  17. package/bin/papyrus-app.mjs +12 -2
  18. package/bin/papyrus-infra.mjs +26 -0
  19. package/components/article-video.tsx +112 -3
  20. package/components/markus-ir-preview.tsx +279 -0
  21. package/components/newsroom-access-gate.tsx +66 -0
  22. package/components/newsroom-article-editor.tsx +637 -0
  23. package/components/newsroom-articles-view.tsx +129 -0
  24. package/components/newsroom-ops-shell.tsx +3 -1
  25. package/components/topic-steering-workspace.tsx +1 -59
  26. package/infra/amplify-app-shell.js +56 -1
  27. package/infra/amplify-app-shell.ts +79 -1
  28. package/infra/github-oidc-provider.js +24 -0
  29. package/infra/github-oidc-provider.ts +30 -0
  30. package/infra/index.js +1 -0
  31. package/infra/site-config.js +31 -1
  32. package/infra/site-config.ts +38 -1
  33. package/lib/content-actions-demo.ts +172 -0
  34. package/lib/newsroom-articles.ts +325 -0
  35. package/lib/newsroom-demo-articles.ts +90 -0
  36. package/lib/newsroom-nav.ts +2 -0
  37. package/lib/site-brand.ts +13 -0
  38. package/lib/staging-preview-object.ts +98 -0
  39. package/lib/video-mode.ts +42 -0
  40. package/lib/video-script.ts +63 -0
  41. package/middleware.ts +14 -1
  42. package/package.json +16 -1
  43. package/routes.manifest.json +27 -1
  44. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  45. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  46. package/scripts/videoml/bundle-common.mjs +129 -0
  47. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  48. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  49. package/scripts/videoml/preview/preview-timing.ts +42 -0
  50. package/scripts/videoml/preview.html +77 -0
  51. package/scripts/videoml/standard-entry.tsx +46 -0
@@ -493,6 +493,12 @@ const schema = a.schema({
493
493
  .returns(a.json())
494
494
  .authorization((allow) => [allow.groups(contentWriteGroups)])
495
495
  .handler(a.handler.function(contentActions)),
496
+ requestStagingBuild: a
497
+ .mutation()
498
+ .arguments({ input: a.json().required() })
499
+ .returns(a.json())
500
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
501
+ .handler(a.handler.function(contentActions)),
496
502
  knowledgeQuery: a
497
503
  .query()
498
504
  .arguments({
@@ -537,6 +537,13 @@ const schema = a.schema({
537
537
  .authorization((allow) => [allow.groups(contentWriteGroups)])
538
538
  .handler(a.handler.function(contentActions)),
539
539
 
540
+ requestStagingBuild: a
541
+ .mutation()
542
+ .arguments({ input: a.json().required() })
543
+ .returns(a.json())
544
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
545
+ .handler(a.handler.function(contentActions)),
546
+
540
547
  knowledgeQuery: a
541
548
  .query()
542
549
  .arguments({
@@ -393,6 +393,12 @@ export const schema = a.schema({
393
393
  .returns(a.json())
394
394
  .authorization((allow) => [allow.groups(contentWriteGroups)])
395
395
  .handler(a.handler.function(contentActions)),
396
+ requestStagingBuild: a
397
+ .mutation()
398
+ .arguments({ input: a.json().required() })
399
+ .returns(a.json())
400
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
401
+ .handler(a.handler.function(contentActions)),
396
402
  knowledgeQuery: a
397
403
  .query()
398
404
  .arguments({
@@ -431,6 +431,13 @@ export const schema = a.schema({
431
431
  .authorization((allow) => [allow.groups(contentWriteGroups)])
432
432
  .handler(a.handler.function(contentActions)),
433
433
 
434
+ requestStagingBuild: a
435
+ .mutation()
436
+ .arguments({ input: a.json().required() })
437
+ .returns(a.json())
438
+ .authorization((allow) => [allow.groups(contentWriteGroups)])
439
+ .handler(a.handler.function(contentActions)),
440
+
434
441
  knowledgeQuery: a
435
442
  .query()
436
443
  .arguments({
@@ -1,10 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import os
4
5
  from typing import Any
5
6
 
6
7
  from papyrus_content.graphql_authoring import create_authoring_client
7
8
  from papyrus_content.markus_renderer.derive import derive_body
9
+ from papyrus_content.rebuild_trigger import (
10
+ DEFAULT_STAGING_BRANCH,
11
+ reader_target_from_environment,
12
+ trigger_pretext_revalidation,
13
+ trigger_rebuild,
14
+ trigger_staging_build,
15
+ )
8
16
  from papyrus_content.publishing import (
9
17
  ItemFields,
10
18
  PublishError,
@@ -72,21 +80,53 @@ def save_item_draft_action(arguments: dict, actor: str) -> dict:
72
80
  }
73
81
 
74
82
 
83
+ def site_triggers_for(client, item_id: str) -> dict:
84
+ triggers: dict = {}
85
+ reader_app_id, reader_branch = reader_target_from_environment()
86
+ revalidate_base_url = (os.environ.get("PAPYRUS_REVALIDATE_BASE_URL") or "").strip()
87
+ if revalidate_base_url:
88
+ try:
89
+ item = client.get_record("Item", item_id) or {}
90
+ slugs = [item["slug"]] if item.get("slug") else []
91
+ revalidated = trigger_pretext_revalidation(slugs, None, base_url=revalidate_base_url)
92
+ triggers["revalidated"] = revalidated if revalidated is not None else {"ok": False, "reason": "not-configured"}
93
+ except Exception as error: # noqa: BLE001 - a trigger failure never fails the publish
94
+ triggers["revalidated"] = {"ok": False, "error": f"{type(error).__name__}: {error}"}
95
+ if reader_app_id:
96
+ triggers["rebuild"] = trigger_rebuild(reader_app_id=reader_app_id, reader_branch=reader_branch)
97
+ return triggers
98
+
99
+
75
100
  def publish_item_action(arguments: dict, actor: str) -> dict:
76
101
  client, _claims = create_authoring_client()
77
102
  result = publish_item(client, arguments["id"], actor=actor)
78
- return {
103
+ response = {
79
104
  "ok": True,
80
105
  "changed": result.changed,
81
106
  "publishedId": result.published_item_id,
82
107
  "versionNumber": result.version_number,
83
108
  }
109
+ if result.changed:
110
+ response.update(site_triggers_for(client, arguments["id"]))
111
+ return response
84
112
 
85
113
 
86
114
  def unpublish_item_action(arguments: dict, actor: str) -> dict:
87
115
  client, _claims = create_authoring_client()
88
116
  result = unpublish_item(client, arguments["id"], actor=actor)
89
- return {"ok": True, "changed": result.changed}
117
+ response = {"ok": True, "changed": result.changed}
118
+ if result.changed:
119
+ response.update(site_triggers_for(client, arguments["id"]))
120
+ return response
121
+
122
+
123
+ def request_staging_build_action() -> dict:
124
+ cms_app_id = (os.environ.get("PAPYRUS_CMS_AMPLIFY_APP_ID") or "").strip()
125
+ if not cms_app_id:
126
+ return {"ok": False, "errors": [{"code": "staging-build-not-configured", "message": "This site has no staging build configured.", "line": None}]}
127
+ staging_branch = (os.environ.get("PAPYRUS_STAGING_BRANCH") or "").strip() or DEFAULT_STAGING_BRANCH
128
+ build = trigger_staging_build(cms_app_id=cms_app_id, staging_branch=staging_branch)
129
+ return {"ok": True, "build": build}
90
130
 
91
131
 
92
132
  def handler(event, context):
@@ -102,6 +142,8 @@ def handler(event, context):
102
142
  return publish_item_action(arguments, actor)
103
143
  if field == "unpublishItem":
104
144
  return unpublish_item_action(arguments, actor)
145
+ if field == "requestStagingBuild":
146
+ return request_staging_build_action()
105
147
  except PublishError as error:
106
148
  return failure_response(error)
107
149
  raise ValueError(f"Unsupported field {field}")
@@ -0,0 +1,22 @@
1
+ export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
2
+ function branchJobsArn(input, appId, branch) {
3
+ return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
4
+ }
5
+ export function rebuildTriggerSettings(input) {
6
+ const environment = {};
7
+ const resources = [];
8
+ const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
9
+ if (readerAppId !== "") {
10
+ const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
11
+ environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
12
+ environment.PAPYRUS_READER_BRANCH = readerBranch;
13
+ resources.push(branchJobsArn(input, readerAppId, readerBranch));
14
+ }
15
+ const cmsAppId = input.cmsAppId.trim();
16
+ if (input.stagingBuildEnabled && cmsAppId !== "") {
17
+ environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
18
+ environment.PAPYRUS_STAGING_BRANCH = "staging";
19
+ resources.push(branchJobsArn(input, cmsAppId, "staging"));
20
+ }
21
+ return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
22
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Environment and IAM policy for the content-actions Lambda's site triggers.
3
+ * Pure (no CDK imports) so the policy can be tested without synthesizing.
4
+ */
5
+ export type RebuildTriggerInput = {
6
+ reader?: { amplifyAppId: string; branchName?: string };
7
+ stagingBuildEnabled: boolean;
8
+ cmsAppId: string;
9
+ region: string;
10
+ account: string;
11
+ };
12
+
13
+ export type RebuildTriggerSettings = {
14
+ environment: Record<string, string>;
15
+ actions: string[];
16
+ resources: string[];
17
+ };
18
+
19
+ export const REBUILD_TRIGGER_ACTIONS = ["amplify:StartJob", "amplify:ListJobs"];
20
+
21
+ function branchJobsArn(input: RebuildTriggerInput, appId: string, branch: string): string {
22
+ return `arn:aws:amplify:${input.region}:${input.account}:apps/${appId}/branches/${branch}/jobs/*`;
23
+ }
24
+
25
+ export function rebuildTriggerSettings(input: RebuildTriggerInput): RebuildTriggerSettings {
26
+ const environment: Record<string, string> = {};
27
+ const resources: string[] = [];
28
+ const readerAppId = (input.reader?.amplifyAppId ?? "").trim();
29
+ if (readerAppId !== "") {
30
+ const readerBranch = (input.reader?.branchName ?? "").trim() || "main";
31
+ environment.PAPYRUS_READER_AMPLIFY_APP_ID = readerAppId;
32
+ environment.PAPYRUS_READER_BRANCH = readerBranch;
33
+ resources.push(branchJobsArn(input, readerAppId, readerBranch));
34
+ }
35
+ const cmsAppId = input.cmsAppId.trim();
36
+ if (input.stagingBuildEnabled && cmsAppId !== "") {
37
+ environment.PAPYRUS_CMS_AMPLIFY_APP_ID = cmsAppId;
38
+ environment.PAPYRUS_STAGING_BRANCH = "staging";
39
+ resources.push(branchJobsArn(input, cmsAppId, "staging"));
40
+ }
41
+ return { environment, actions: REBUILD_TRIGGER_ACTIONS, resources };
42
+ }
@@ -21,6 +21,12 @@ export type PapyrusSiteBackendConfig = {
21
21
  */
22
22
  productionAppId?: string;
23
23
  auth?: PapyrusAuthConfig;
24
+ /** Static reader app (Markus-static sites) that a publish rebuilds through IAM `amplify:StartJob`. */
25
+ reader?: { amplifyAppId: string; branchName?: string };
26
+ /** Lets the newsroom start the `staging` branch build of this CMS app. */
27
+ stagingBuild?: { enabled: boolean };
28
+ /** Base URL of a Pretext reader whose `/api/revalidate` a publish calls (secret `PAPYRUS_REVALIDATE_SECRET`). */
29
+ revalidateBaseUrl?: string;
24
30
  features?: {
25
31
  consoleResponder?: boolean;
26
32
  inboundEmail?: boolean;
@@ -12,6 +12,7 @@ import { CfnEventSourceMapping, FunctionUrlAuthType } from "aws-cdk-lib/aws-lamb
12
12
  import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
13
13
  import { dirname, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
15
16
  import { authConfigFromEnv, defineSiteAuth } from "./auth/resource.js";
16
17
  import { data } from "./data/resource.js";
17
18
  import { assignmentAction } from "./functions/assignment-action/resource.js";
@@ -333,6 +334,23 @@ export function defineSiteBackend(papyrusSite) {
333
334
  actions: ["appsync:GraphQL"],
334
335
  resources: ["*"],
335
336
  }));
337
+ const rebuildSettings = rebuildTriggerSettings({
338
+ reader: site.reader,
339
+ stagingBuildEnabled: site.stagingBuild?.enabled === true,
340
+ cmsAppId: amplifyAppId,
341
+ region: Stack.of(contentActionsLambda).region,
342
+ account: Stack.of(contentActionsLambda).account,
343
+ });
344
+ for (const [name, value] of Object.entries(rebuildSettings.environment)) {
345
+ contentActionsLambda.addEnvironment(name, value);
346
+ }
347
+ if (rebuildSettings.resources.length > 0) {
348
+ contentActionsLambda.addToRolePolicy(new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }));
349
+ }
350
+ if ((site.revalidateBaseUrl ?? "").trim() !== "") {
351
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
352
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
353
+ }
336
354
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
337
355
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda);
338
356
  if (backend.sesInboundReceive) {
@@ -13,6 +13,7 @@ import * as s3 from "aws-cdk-lib/aws-s3";
13
13
  import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aws-s3vectors";
14
14
  import { dirname, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
+ import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
16
17
  import { authConfigFromEnv, defineSiteAuth } from "./auth/resource";
17
18
  import { data } from "./data/resource";
18
19
  import { assignmentAction } from "./functions/assignment-action/resource";
@@ -414,6 +415,25 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
414
415
  resources: ["*"],
415
416
  }),
416
417
  );
418
+ const rebuildSettings = rebuildTriggerSettings({
419
+ reader: site.reader,
420
+ stagingBuildEnabled: site.stagingBuild?.enabled === true,
421
+ cmsAppId: amplifyAppId,
422
+ region: Stack.of(contentActionsLambda).region,
423
+ account: Stack.of(contentActionsLambda).account,
424
+ });
425
+ for (const [name, value] of Object.entries(rebuildSettings.environment)) {
426
+ contentActionsLambda.addEnvironment(name, value);
427
+ }
428
+ if (rebuildSettings.resources.length > 0) {
429
+ contentActionsLambda.addToRolePolicy(
430
+ new PolicyStatement({ actions: rebuildSettings.actions, resources: rebuildSettings.resources }),
431
+ );
432
+ }
433
+ if ((site.revalidateBaseUrl ?? "").trim() !== "") {
434
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_BASE_URL", (site.revalidateBaseUrl ?? "").trim());
435
+ backend.contentActions.addEnvironment("PAPYRUS_REVALIDATE_SECRET", secret("PAPYRUS_REVALIDATE_SECRET"));
436
+ }
417
437
  grantNewsroomReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
418
438
  grantMediaReadWriteDelete(backend.modelAttachmentUpload.resources.lambda as LambdaFunction);
419
439
  if (backend.sesInboundReceive) {
@@ -11,6 +11,9 @@ export const storage = defineStorage({
11
11
  "corpora/*": [
12
12
  allow.groups(["editor", "admin"]).to(["read"]),
13
13
  ],
14
+ "preview/*": [
15
+ allow.groups(["editor", "admin"]).to(["read"]),
16
+ ],
14
17
  "newsroom/*": [
15
18
  allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
16
19
  ],
@@ -12,6 +12,9 @@ export const storage = defineStorage({
12
12
  "corpora/*": [
13
13
  allow.groups(["editor", "admin"]).to(["read"]),
14
14
  ],
15
+ "preview/*": [
16
+ allow.groups(["editor", "admin"]).to(["read"]),
17
+ ],
15
18
  "newsroom/*": [
16
19
  allow.groups(["editor", "admin"]).to(["read", "write", "delete"]),
17
20
  ],
@@ -0,0 +1,8 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { streamPreviewObject } from "../../../lib/staging-preview-object";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(request: NextRequest): Promise<Response> {
7
+ return streamPreviewObject(request.nextUrl.pathname);
8
+ }
package/app/globals.css CHANGED
@@ -2282,6 +2282,25 @@ img {
2282
2282
  width: 100%;
2283
2283
  }
2284
2284
 
2285
+ .article-video__preview-frame {
2286
+ aspect-ratio: 16 / 9;
2287
+ background: #000;
2288
+ border: 0;
2289
+ display: block;
2290
+ width: 100%;
2291
+ }
2292
+
2293
+ .article-video__preview-controls {
2294
+ align-items: center;
2295
+ display: flex;
2296
+ gap: 0.75rem;
2297
+ margin-top: 0.5rem;
2298
+ }
2299
+
2300
+ .article-video__preview-controls input[type="range"] {
2301
+ flex: 1;
2302
+ }
2303
+
2285
2304
  .article-video__credit {
2286
2305
  color: rgb(var(--ink-solid-rgb) / 0.62);
2287
2306
  font-size: 0.82rem;
@@ -0,0 +1,15 @@
1
+ import { NewsroomArticleEditor } from "../../../../components/newsroom-article-editor";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ type NewsroomArticleEditorPageProps = {
6
+ params: Promise<{ id: string }>;
7
+ searchParams?: Promise<{ demo?: string | string[] }>;
8
+ };
9
+
10
+ export default async function NewsroomArticleEditorPage({ params, searchParams }: NewsroomArticleEditorPageProps) {
11
+ const { id } = await params;
12
+ const resolved = await searchParams;
13
+ const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
14
+ return <NewsroomArticleEditor articleId={decodeURIComponent(id)} demo={demoValue === "1"} />;
15
+ }
@@ -0,0 +1,13 @@
1
+ import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
2
+
3
+ export const dynamic = "force-dynamic";
4
+
5
+ type NewsroomArticlesPageProps = {
6
+ searchParams?: Promise<{ demo?: string | string[] }>;
7
+ };
8
+
9
+ export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
10
+ const resolved = await searchParams;
11
+ const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
12
+ return <NewsroomArticlesView demo={demoValue === "1"} />;
13
+ }
@@ -1,10 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { sync } from "../src/sync.mjs";
3
3
 
4
- const [command] = process.argv.slice(2);
4
+ const [command, ...rest] = process.argv.slice(2);
5
5
  if (command === "sync") {
6
6
  sync();
7
+ } else if (command === "videoml-bundle") {
8
+ const preview = rest.includes("--preview");
9
+ const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
10
+ const { buildPreviewBundle } = await import("../scripts/videoml/build-preview-bundle.mjs");
11
+ try {
12
+ await (preview ? buildPreviewBundle() : buildBrowserBundle());
13
+ } catch (error) {
14
+ console.error(error.message);
15
+ process.exit(1);
16
+ }
7
17
  } else {
8
- console.error("Usage: papyrus-app sync");
18
+ console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
9
19
  process.exit(command ? 1 : 0);
10
20
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
4
+ * papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]
4
5
  * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
5
6
  * Synthesizes the app-shell stack for a publication from its own site.json, or
6
7
  * prints one of the generated Amplify build specs. Synth only: deploying is a
@@ -14,6 +15,7 @@ import { parseSiteConfig } from "../infra/site-config.js";
14
15
 
15
16
  const USAGE = [
16
17
  "Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
18
+ " papyrus-infra synth --account-stack github-oidc [--out cdk.out] [--account A] [--region R]",
17
19
  " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
18
20
  ].join("\n");
19
21
 
@@ -28,6 +30,30 @@ if (command !== "synth" && command !== "print-buildspec") {
28
30
  process.exit(command ? 1 : 0);
29
31
  }
30
32
 
33
+ const accountStack = opt("account-stack");
34
+ if (accountStack !== undefined) {
35
+ if (command !== "synth" || accountStack !== "github-oidc") {
36
+ console.error(USAGE);
37
+ process.exit(1);
38
+ }
39
+ let GithubOidcProviderStack;
40
+ try {
41
+ ({ GithubOidcProviderStack } = await import("../infra/index.js"));
42
+ } catch (error) {
43
+ console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
44
+ process.exit(1);
45
+ }
46
+ const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
47
+ const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
48
+ new GithubOidcProviderStack(app, "GithubOidcProviderStack", {
49
+ env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
50
+ stackName: "github-oidc-provider",
51
+ });
52
+ app.synth();
53
+ console.log(`synthesized github-oidc-provider -> ${path.resolve(opt("out", "cdk.out"))}`);
54
+ process.exit(0);
55
+ }
56
+
31
57
  let config;
32
58
  try {
33
59
  config = parseSiteConfig(JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8")));
@@ -1,31 +1,140 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import type { ArticleVideoAsset } from "../lib/articles";
5
5
  import { resolveThemedVideoSrc } from "../lib/themed-image";
6
6
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
7
+ import { getSiteBrand } from "../lib/site-brand";
8
+ import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
9
+ import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
7
10
 
8
11
  type ArticleVideoFigureProps = {
9
12
  video: ArticleVideoAsset;
10
13
  slug: string;
11
14
  figureClassName?: string;
12
15
  priority?: boolean;
16
+ videoScript?: VideoScriptRef | null;
13
17
  };
14
18
 
15
- export function ArticleVideoFigure({ video, slug, figureClassName = "article-photo article-video" }: ArticleVideoFigureProps) {
19
+ const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
20
+
21
+ export function ArticleVideoFigure({
22
+ video,
23
+ slug,
24
+ figureClassName = "article-photo article-video",
25
+ videoScript = null,
26
+ }: ArticleVideoFigureProps) {
16
27
  const theme = useResolvedPapyrusTheme();
17
28
  const [hasHydrated, setHasHydrated] = useState(false);
29
+ const [videoMode, setVideoMode] = useState<VideoMode>(() => resolveVideoModeFromEnv());
30
+ const [isPlaying, setIsPlaying] = useState(false);
31
+ const [currentTime, setCurrentTime] = useState(0);
32
+ const [duration, setDuration] = useState(0);
33
+ const [previewReady, setPreviewReady] = useState(false);
34
+ const iframeRef = useRef<HTMLIFrameElement>(null);
18
35
 
19
36
  useEffect(() => {
20
37
  setHasHydrated(true);
38
+ setVideoMode(resolveVideoMode());
21
39
  }, []);
22
40
 
23
41
  // Match SSR to the default dark MP4 in `video.src` until after hydration.
24
42
  const resolvedTheme = hasHydrated ? theme : "dark";
25
43
  const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
44
+ const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
45
+ const previewDsl = videoScript?.dsl
46
+ ? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
47
+ : null;
48
+ const previewSrc = `/videoml/preview.html?target=${encodeURIComponent(slug)}&theme=${resolvedTheme}${
49
+ src ? `&audio=${encodeURIComponent(src)}` : ""
50
+ }`;
51
+
52
+ if (usePreview && previewDsl && typeof window !== "undefined") {
53
+ sessionStorage.setItem(`${PREVIEW_STORAGE_PREFIX}${slug}`, previewDsl);
54
+ }
55
+
56
+ useEffect(() => {
57
+ setPreviewReady(false);
58
+ setIsPlaying(false);
59
+ setCurrentTime(0);
60
+ setDuration(0);
61
+ }, [previewSrc, usePreview]);
62
+
63
+ useEffect(() => {
64
+ if (!usePreview || !previewReady) return;
65
+ iframeRef.current?.contentWindow?.postMessage(
66
+ { kind: "papyrus-video-refresh", target: slug, theme: resolvedTheme, audio: src || undefined },
67
+ "*",
68
+ );
69
+ }, [previewReady, resolvedTheme, slug, src, usePreview]);
70
+
71
+ useEffect(() => {
72
+ if (!usePreview) return;
73
+ const onMessage = (event: MessageEvent) => {
74
+ const payload = event.data;
75
+ if (!payload || typeof payload !== "object" || payload.kind !== "papyrus-video-time") return;
76
+ if (typeof payload.currentTime === "number") setCurrentTime(payload.currentTime);
77
+ if (typeof payload.duration === "number") setDuration(payload.duration);
78
+ };
79
+ window.addEventListener("message", onMessage);
80
+ return () => window.removeEventListener("message", onMessage);
81
+ }, [usePreview]);
82
+
83
+ const togglePreviewPlayback = useCallback(() => {
84
+ iframeRef.current?.contentWindow?.postMessage({ kind: isPlaying ? "papyrus-video-pause" : "papyrus-video-play" }, "*");
85
+ setIsPlaying(!isPlaying);
86
+ }, [isPlaying]);
87
+
88
+ const seekPreview = useCallback((nextTime: number) => {
89
+ const clamped = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
90
+ iframeRef.current?.contentWindow?.postMessage({ kind: "papyrus-video-seek", time: clamped }, "*");
91
+ setCurrentTime(clamped);
92
+ }, []);
93
+
94
+ if (usePreview) {
95
+ return (
96
+ <figure
97
+ className={`${figureClassName} article-video--preview`}
98
+ data-media-type="videoml-preview"
99
+ data-video-theme={resolvedTheme}
100
+ data-video-mode={videoMode}
101
+ >
102
+ {hasHydrated ? (
103
+ <iframe
104
+ key={`${slug}-${resolvedTheme}-${src}`}
105
+ ref={iframeRef}
106
+ className="article-video__preview-frame"
107
+ src={previewSrc}
108
+ title={video.alt}
109
+ onLoad={() => setPreviewReady(true)}
110
+ />
111
+ ) : null}
112
+ <div className="article-video__preview-controls">
113
+ <button type="button" onClick={togglePreviewPlayback} aria-pressed={isPlaying}>
114
+ {isPlaying ? "Pause video" : "Play video"}
115
+ </button>
116
+ <input
117
+ type="range"
118
+ min={0}
119
+ max={duration || 0}
120
+ step={0.1}
121
+ value={Math.min(currentTime, duration || 0)}
122
+ onChange={(event) => seekPreview(Number(event.target.value))}
123
+ aria-label="Seek video"
124
+ disabled={!duration}
125
+ />
126
+ </div>
127
+ {video.caption ? <figcaption>{video.caption}</figcaption> : null}
128
+ {video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
129
+ <span className="sr-only" data-video-slug={slug}>
130
+ {video.alt}
131
+ </span>
132
+ </figure>
133
+ );
134
+ }
26
135
 
27
136
  return (
28
- <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme}>
137
+ <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
29
138
  <video
30
139
  controls
31
140
  playsInline