@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
@@ -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;
package/app/layout.tsx CHANGED
@@ -6,6 +6,8 @@ import { PapyrusConsoleShell } from "../components/papyrus-console-shell";
6
6
  import { TooltipProvider } from "../components/ui/tooltip";
7
7
  import { getPresentationChoices, getSiteBrand } from "../lib/site-brand";
8
8
  import { getSiteStack } from "../lib/site-stack";
9
+ import { isIndexable, showStagingBanner } from "../lib/site-env";
10
+ import { StagingBanner } from "../components/staging-banner";
9
11
  import "./tailwind.css";
10
12
  import "./globals.css";
11
13
  import "papyrus-site-theme";
@@ -27,6 +29,7 @@ export async function generateMetadata(): Promise<Metadata> {
27
29
  return {
28
30
  title: siteBrand.appTitle,
29
31
  description: siteBrand.appDescription,
32
+ ...(isIndexable() ? {} : { robots: { index: false, follow: false } }),
30
33
  icons: {
31
34
  icon: [
32
35
  { url: `/icon-light.png?v=${faviconVersion}`, type: "image/png" },
@@ -127,6 +130,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
127
130
  window.addEventListener("papyrus:settings-changed", apply);
128
131
  })();`}
129
132
  </Script>
133
+ {showStagingBanner() ? <StagingBanner /> : null}
130
134
  <AmplifyClientProvider>
131
135
  <TooltipProvider>
132
136
  <PapyrusConsoleShell>{children}</PapyrusConsoleShell>
package/app/robots.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { isIndexable } from "../lib/site-env";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export default function robots(): MetadataRoute.Robots {
7
+ if (isIndexable()) {
8
+ return { rules: { userAgent: "*", allow: "/" } };
9
+ }
10
+ return { rules: { userAgent: "*", disallow: "/" } };
11
+ }
@@ -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,35 +1,91 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * papyrus-infra synth --site infra/site.json [--out cdk.out]
4
- * Synthesizes the app-shell stack for a publication from its own site.json
5
- * (SPIKE, PPY-82be6c). Synth only: deploying is a separate, reviewed step.
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]
5
+ * papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
6
+ * Synthesizes the app-shell stack for a publication from its own site.json, or
7
+ * prints one of the generated Amplify build specs. Synth only: deploying is a
8
+ * separate, reviewed step.
6
9
  */
7
10
  import fs from "node:fs";
8
11
  import path from "node:path";
9
12
  import { createRequire } from "node:module";
13
+ import { buildSpecFor } from "../infra/build-specs.js";
14
+ import { parseSiteConfig } from "../infra/site-config.js";
15
+
16
+ const USAGE = [
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]",
19
+ " papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
20
+ ].join("\n");
21
+
22
+ const args = process.argv.slice(2);
23
+ const opt = (name, fallback) => {
24
+ const i = args.indexOf(`--${name}`);
25
+ return i >= 0 ? args[i + 1] : fallback;
26
+ };
27
+ const command = args[0];
28
+ if (command !== "synth" && command !== "print-buildspec") {
29
+ console.error(USAGE);
30
+ process.exit(command ? 1 : 0);
31
+ }
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
+
57
+ let config;
58
+ try {
59
+ config = parseSiteConfig(JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8")));
60
+ } catch (error) {
61
+ console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ if (command === "print-buildspec") {
66
+ const app = opt("app");
67
+ if (!["cms-production", "cms-staging", "reader"].includes(app)) {
68
+ console.error(USAGE);
69
+ process.exit(1);
70
+ }
71
+ try {
72
+ process.stdout.write(buildSpecFor(config, app));
73
+ } catch (error) {
74
+ console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
75
+ process.exit(1);
76
+ }
77
+ process.exit(0);
78
+ }
10
79
 
11
80
  let AmplifyAppShellStack;
12
81
  try {
13
82
  ({ AmplifyAppShellStack } = await import("../infra/index.js"));
14
83
  } catch (error) {
15
- // infra/index.js explains the missing optional peers; print it without a stack trace.
16
84
  console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
17
85
  process.exit(1);
18
86
  }
19
87
 
20
- // aws-cdk-lib is an optional peer: resolve it from the publication's infra/ package (cwd).
21
88
  const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
22
-
23
- const args = process.argv.slice(2);
24
- const opt = (name, fallback) => {
25
- const i = args.indexOf(`--${name}`);
26
- return i >= 0 ? args[i + 1] : fallback;
27
- };
28
- if (args[0] !== "synth") {
29
- console.error("Usage: papyrus-infra synth --site infra/site.json [--out cdk.out]");
30
- process.exit(args[0] ? 1 : 0);
31
- }
32
- const config = JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8"));
33
89
  const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
34
90
  new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
35
91
  env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
@@ -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
@@ -0,0 +1,11 @@
1
+ export function StagingBanner() {
2
+ return (
3
+ <div
4
+ data-staging-banner
5
+ role="status"
6
+ className="sticky top-0 z-50 bg-amber-300 px-3 py-1 text-center text-xs font-semibold uppercase tracking-wide text-black"
7
+ >
8
+ STAGING: drafts shown as published
9
+ </div>
10
+ );
11
+ }