@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.20
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.
- package/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +3 -1
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-page.tsx +1 -4
- package/components/newspaper.tsx +2 -1
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +31 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +273 -0
- package/infra/site-config.ts +336 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +3 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +201 -42
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +7 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +63 -0
- package/middleware.ts +56 -42
- package/package.json +39 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +280 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +37 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +15 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
package/app/layout.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import { Playfair_Display, Plus_Jakarta_Sans } from "next/font/google";
|
|
3
|
-
import { cookies } from "next/headers";
|
|
4
3
|
import Script from "next/script";
|
|
5
4
|
import { AmplifyClientProvider } from "../components/amplify-client-provider";
|
|
6
5
|
import { PapyrusConsoleShell } from "../components/papyrus-console-shell";
|
|
7
6
|
import { TooltipProvider } from "../components/ui/tooltip";
|
|
8
|
-
import {
|
|
7
|
+
import { resolveBrandFontAssets } from "../lib/brand-fonts";
|
|
8
|
+
import { getPresentationChoices, getSiteBrand } from "../lib/site-brand";
|
|
9
9
|
import { getSiteStack } from "../lib/site-stack";
|
|
10
|
+
import { isIndexable, showStagingBanner } from "../lib/site-env";
|
|
11
|
+
import { StagingBanner } from "../components/staging-banner";
|
|
10
12
|
import "./tailwind.css";
|
|
11
13
|
import "./globals.css";
|
|
14
|
+
import "./blog-rhythm.css";
|
|
15
|
+
import "papyrus-site-theme";
|
|
12
16
|
|
|
13
17
|
const playfairDisplay = Playfair_Display({
|
|
14
18
|
subsets: ["latin"],
|
|
@@ -22,19 +26,12 @@ const plusJakartaSans = Plus_Jakarta_Sans({
|
|
|
22
26
|
|
|
23
27
|
const faviconVersion = "20260517-1";
|
|
24
28
|
const defaultTheme = "system";
|
|
25
|
-
const BRAND_OVERRIDE_COOKIE = "papyrus-site-brand-override";
|
|
26
|
-
|
|
27
|
-
async function resolveLayoutSiteBrand() {
|
|
28
|
-
const cookieStore = await cookies();
|
|
29
|
-
const brandId = resolveRuntimeSiteBrandId(cookieStore.get(BRAND_OVERRIDE_COOKIE)?.value ?? null);
|
|
30
|
-
return getSiteBrand(brandId);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
export async function generateMetadata(): Promise<Metadata> {
|
|
34
|
-
const siteBrand =
|
|
30
|
+
const siteBrand = getSiteBrand();
|
|
35
31
|
return {
|
|
36
32
|
title: siteBrand.appTitle,
|
|
37
33
|
description: siteBrand.appDescription,
|
|
34
|
+
...(isIndexable() ? {} : { robots: { index: false, follow: false } }),
|
|
38
35
|
icons: {
|
|
39
36
|
icon: [
|
|
40
37
|
{ url: `/icon-light.png?v=${faviconVersion}`, type: "image/png" },
|
|
@@ -45,9 +42,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
48
|
-
const siteBrand =
|
|
45
|
+
const siteBrand = getSiteBrand();
|
|
49
46
|
const presentationChoices = getPresentationChoices(siteBrand);
|
|
50
47
|
const siteStack = getSiteStack(siteBrand);
|
|
48
|
+
const brandFonts = resolveBrandFontAssets(siteBrand.fonts);
|
|
51
49
|
|
|
52
50
|
return (
|
|
53
51
|
<html
|
|
@@ -61,8 +59,21 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|
|
61
59
|
data-default-presentation={siteBrand.defaultPresentation}
|
|
62
60
|
data-presentation-choices={presentationChoices.join(",")}
|
|
63
61
|
{...(siteBrand.forcedPresentation ? { "data-forced-presentation": siteBrand.forcedPresentation } : {})}
|
|
62
|
+
{...(siteBrand.blogLayout === "rhythm" ? { "data-blog-layout": "rhythm" } : {})}
|
|
63
|
+
{...(brandFonts.isEmpty ? {} : { style: brandFonts.rootStyle as React.CSSProperties })}
|
|
64
64
|
suppressHydrationWarning
|
|
65
65
|
>
|
|
66
|
+
{brandFonts.isEmpty ? null : (
|
|
67
|
+
<head>
|
|
68
|
+
{brandFonts.preconnectOrigins.map((origin) => (
|
|
69
|
+
<link crossOrigin="anonymous" href={origin} key={origin} rel="preconnect" />
|
|
70
|
+
))}
|
|
71
|
+
{brandFonts.stylesheetHrefs.map((href) => (
|
|
72
|
+
<link href={href} key={href} rel="stylesheet" />
|
|
73
|
+
))}
|
|
74
|
+
{brandFonts.fontFaceCss ? <style data-brand-fonts="true">{brandFonts.fontFaceCss}</style> : null}
|
|
75
|
+
</head>
|
|
76
|
+
)}
|
|
66
77
|
<body className={`${playfairDisplay.variable} ${plusJakartaSans.variable}`}>
|
|
67
78
|
<Script id="papyrus-favicon-color-scheme" strategy="beforeInteractive">
|
|
68
79
|
{`(() => {
|
|
@@ -135,6 +146,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|
|
135
146
|
window.addEventListener("papyrus:settings-changed", apply);
|
|
136
147
|
})();`}
|
|
137
148
|
</Script>
|
|
149
|
+
{showStagingBanner() ? <StagingBanner /> : null}
|
|
138
150
|
<AmplifyClientProvider>
|
|
139
151
|
<TooltipProvider>
|
|
140
152
|
<PapyrusConsoleShell>{children}</PapyrusConsoleShell>
|
|
@@ -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
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -15,7 +15,9 @@ import {
|
|
|
15
15
|
} from "../lib/public-placeholder-config";
|
|
16
16
|
import type { PublicationItem } from "../lib/publication-items";
|
|
17
17
|
import { SITE_BRAND, rootRoute } from "../lib/site-brand";
|
|
18
|
-
import { redirect } from "next/navigation";
|
|
18
|
+
import { notFound, redirect } from "next/navigation";
|
|
19
|
+
import { getReaderBasePath } from "../lib/reader-base-path";
|
|
20
|
+
import { buildRootRedirectTarget } from "../lib/root-route-redirect";
|
|
19
21
|
import NewsDeskRootPage, { type NewsDeskRootSearchParams } from "./newsroom/page";
|
|
20
22
|
|
|
21
23
|
export const dynamic = "force-dynamic";
|
|
@@ -37,12 +39,18 @@ export default async function Home({ searchParams }: HomePageProps) {
|
|
|
37
39
|
// co-hosts reader + CMS uses the default "reader" (render the publication
|
|
38
40
|
// home page here). A CMS-only site points the root at the newsroom or
|
|
39
41
|
// anywhere else. Preserve the scenario and OAuth-callback paths.
|
|
40
|
-
|
|
42
|
+
const publicationOwnsRoot = getReaderBasePath() !== "";
|
|
43
|
+
|
|
44
|
+
if (!publicationOwnsRoot && rootRoute.kind === "newsroom" && !scenarioId) {
|
|
41
45
|
return <NewsDeskRootPage searchParams={searchParams} />;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
if (rootRoute.kind === "redirect"
|
|
45
|
-
redirect(rootRoute.destination);
|
|
48
|
+
if (!publicationOwnsRoot && rootRoute.kind === "redirect") {
|
|
49
|
+
redirect(buildRootRedirectTarget(rootRoute.destination, resolvedSearchParams));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (SITE_BRAND.renderer.kind === "markus") {
|
|
53
|
+
notFound();
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
// Resolved only on the reader path: Markus brands (renderer "markus") have
|
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
|
+
}
|
package/bin/papyrus-app.mjs
CHANGED
|
@@ -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
|
}
|
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -1,39 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* papyrus-infra synth --site infra/site.json [--out cdk.out]
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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, resolveStackName } 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") },
|
|
36
|
-
stackName:
|
|
92
|
+
stackName: resolveStackName(config),
|
|
37
93
|
});
|
|
38
94
|
app.synth();
|
|
39
|
-
console.log(`synthesized
|
|
95
|
+
console.log(`synthesized ${resolveStackName(config)} -> ${path.resolve(opt("out", "cdk.out"))}`);
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { useRhythmOverlay } from "./use-rhythm-overlay";
|
|
5
5
|
|
|
6
6
|
type ArchiveShellProps = {
|
|
7
7
|
children: ReactNode;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export function ArchiveShell({ children }: ArchiveShellProps) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
15
|
-
if (isEditableEventTarget(event.target)) return;
|
|
16
|
-
if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
17
|
-
event.preventDefault();
|
|
18
|
-
setShowRhythmOverlay((current) => !current);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
23
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
24
|
-
}, []);
|
|
11
|
+
const showRhythmOverlay = useRhythmOverlay();
|
|
25
12
|
|
|
26
13
|
return (
|
|
27
14
|
<main
|
|
@@ -32,10 +19,3 @@ export function ArchiveShell({ children }: ArchiveShellProps) {
|
|
|
32
19
|
</main>
|
|
33
20
|
);
|
|
34
21
|
}
|
|
35
|
-
|
|
36
|
-
function isEditableEventTarget(target: EventTarget | null): boolean {
|
|
37
|
-
if (!(target instanceof HTMLElement)) return false;
|
|
38
|
-
const tagName = target.tagName.toLowerCase();
|
|
39
|
-
if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
|
|
40
|
-
return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
|
|
41
|
-
}
|
|
@@ -1,31 +1,232 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { Pause, Play } from "lucide-react";
|
|
4
5
|
import type { ArticleVideoAsset } from "../lib/articles";
|
|
5
6
|
import { resolveThemedVideoSrc } from "../lib/themed-image";
|
|
6
7
|
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
8
|
+
import { getSiteBrand } from "../lib/site-brand";
|
|
9
|
+
import { normalizeDevPreviewDsl, type VideoScriptRef } from "../lib/video-script";
|
|
10
|
+
import { resolveVideoMode, resolveVideoModeFromEnv, type VideoMode } from "../lib/video-mode";
|
|
7
11
|
|
|
8
12
|
type ArticleVideoFigureProps = {
|
|
9
13
|
video: ArticleVideoAsset;
|
|
10
14
|
slug: string;
|
|
11
15
|
figureClassName?: string;
|
|
12
16
|
priority?: boolean;
|
|
17
|
+
videoScript?: VideoScriptRef | null;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
const PREVIEW_STORAGE_PREFIX = "papyrus-video-preview:";
|
|
21
|
+
|
|
22
|
+
export function ArticleVideoFigure({
|
|
23
|
+
video,
|
|
24
|
+
slug,
|
|
25
|
+
figureClassName = "article-photo article-video",
|
|
26
|
+
videoScript = null,
|
|
27
|
+
}: ArticleVideoFigureProps) {
|
|
16
28
|
const theme = useResolvedPapyrusTheme();
|
|
17
29
|
const [hasHydrated, setHasHydrated] = useState(false);
|
|
30
|
+
const [videoMode, setVideoMode] = useState<VideoMode>(() => resolveVideoModeFromEnv());
|
|
31
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
32
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
33
|
+
const [duration, setDuration] = useState(0);
|
|
34
|
+
const [previewReady, setPreviewReady] = useState(false);
|
|
35
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
36
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
37
|
+
const isFramed = getSiteBrand().videoPlayer === "framed";
|
|
18
38
|
|
|
19
39
|
useEffect(() => {
|
|
20
40
|
setHasHydrated(true);
|
|
41
|
+
setVideoMode(resolveVideoMode());
|
|
21
42
|
}, []);
|
|
22
43
|
|
|
23
44
|
// Match SSR to the default dark MP4 in `video.src` until after hydration.
|
|
24
45
|
const resolvedTheme = hasHydrated ? theme : "dark";
|
|
25
46
|
const src = resolveThemedVideoSrc(video.src, video.themeVariants, resolvedTheme);
|
|
47
|
+
const usePreview = videoMode === "preview" && Boolean(videoScript?.dsl);
|
|
48
|
+
const previewDsl = videoScript?.dsl
|
|
49
|
+
? normalizeDevPreviewDsl(videoScript.dsl, getSiteBrand().video?.sceneComponents)
|
|
50
|
+
: null;
|
|
51
|
+
const previewSrc = `/videoml/preview.html?target=${encodeURIComponent(slug)}&theme=${resolvedTheme}${
|
|
52
|
+
src ? `&audio=${encodeURIComponent(src)}` : ""
|
|
53
|
+
}`;
|
|
54
|
+
|
|
55
|
+
if (usePreview && previewDsl && typeof window !== "undefined") {
|
|
56
|
+
sessionStorage.setItem(`${PREVIEW_STORAGE_PREFIX}${slug}`, previewDsl);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setPreviewReady(false);
|
|
61
|
+
setIsPlaying(false);
|
|
62
|
+
setCurrentTime(0);
|
|
63
|
+
setDuration(0);
|
|
64
|
+
}, [previewSrc, usePreview]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!usePreview || !previewReady) return;
|
|
68
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
69
|
+
{ kind: "papyrus-video-refresh", target: slug, theme: resolvedTheme, audio: src || undefined },
|
|
70
|
+
"*",
|
|
71
|
+
);
|
|
72
|
+
}, [previewReady, resolvedTheme, slug, src, usePreview]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!usePreview) return;
|
|
76
|
+
const onMessage = (event: MessageEvent) => {
|
|
77
|
+
const payload = event.data;
|
|
78
|
+
if (!payload || typeof payload !== "object" || payload.kind !== "papyrus-video-time") return;
|
|
79
|
+
if (typeof payload.currentTime === "number") setCurrentTime(payload.currentTime);
|
|
80
|
+
if (typeof payload.duration === "number") setDuration(payload.duration);
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener("message", onMessage);
|
|
83
|
+
return () => window.removeEventListener("message", onMessage);
|
|
84
|
+
}, [usePreview]);
|
|
85
|
+
|
|
86
|
+
const togglePreviewPlayback = useCallback(() => {
|
|
87
|
+
iframeRef.current?.contentWindow?.postMessage({ kind: isPlaying ? "papyrus-video-pause" : "papyrus-video-play" }, "*");
|
|
88
|
+
setIsPlaying(!isPlaying);
|
|
89
|
+
}, [isPlaying]);
|
|
90
|
+
|
|
91
|
+
const seekPreview = useCallback((nextTime: number) => {
|
|
92
|
+
const clamped = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
|
|
93
|
+
iframeRef.current?.contentWindow?.postMessage({ kind: "papyrus-video-seek", time: clamped }, "*");
|
|
94
|
+
setCurrentTime(clamped);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const toggleNativePlayback = useCallback(() => {
|
|
98
|
+
const element = videoRef.current;
|
|
99
|
+
if (!element) return;
|
|
100
|
+
if (element.paused) void element.play().catch(() => setIsPlaying(false));
|
|
101
|
+
else element.pause();
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const seekNative = useCallback((nextTime: number) => {
|
|
105
|
+
const element = videoRef.current;
|
|
106
|
+
if (!element) return;
|
|
107
|
+
element.currentTime = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
|
|
108
|
+
setCurrentTime(element.currentTime);
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const framedControls = (
|
|
112
|
+
<FramedVideoControls
|
|
113
|
+
currentTime={currentTime}
|
|
114
|
+
duration={duration}
|
|
115
|
+
isPlaying={isPlaying}
|
|
116
|
+
onSeek={usePreview ? seekPreview : seekNative}
|
|
117
|
+
onToggle={usePreview ? togglePreviewPlayback : toggleNativePlayback}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (isFramed && usePreview) {
|
|
122
|
+
return (
|
|
123
|
+
<figure
|
|
124
|
+
className={`${figureClassName} article-video--framed article-video--preview`}
|
|
125
|
+
data-media-type="videoml-preview"
|
|
126
|
+
data-video-theme={resolvedTheme}
|
|
127
|
+
data-video-mode={videoMode}
|
|
128
|
+
>
|
|
129
|
+
<div className="article-video__media">
|
|
130
|
+
{hasHydrated ? (
|
|
131
|
+
<iframe
|
|
132
|
+
key={`${slug}-${resolvedTheme}-${src}`}
|
|
133
|
+
ref={iframeRef}
|
|
134
|
+
className="article-video__preview-frame"
|
|
135
|
+
src={previewSrc}
|
|
136
|
+
title={video.alt}
|
|
137
|
+
onLoad={() => setPreviewReady(true)}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
{framedControls}
|
|
142
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
143
|
+
{video.alt}
|
|
144
|
+
</span>
|
|
145
|
+
</figure>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (usePreview) {
|
|
150
|
+
return (
|
|
151
|
+
<figure
|
|
152
|
+
className={`${figureClassName} article-video--preview`}
|
|
153
|
+
data-media-type="videoml-preview"
|
|
154
|
+
data-video-theme={resolvedTheme}
|
|
155
|
+
data-video-mode={videoMode}
|
|
156
|
+
>
|
|
157
|
+
{hasHydrated ? (
|
|
158
|
+
<iframe
|
|
159
|
+
key={`${slug}-${resolvedTheme}-${src}`}
|
|
160
|
+
ref={iframeRef}
|
|
161
|
+
className="article-video__preview-frame"
|
|
162
|
+
src={previewSrc}
|
|
163
|
+
title={video.alt}
|
|
164
|
+
onLoad={() => setPreviewReady(true)}
|
|
165
|
+
/>
|
|
166
|
+
) : null}
|
|
167
|
+
<div className="article-video__preview-controls">
|
|
168
|
+
<button type="button" onClick={togglePreviewPlayback} aria-pressed={isPlaying}>
|
|
169
|
+
{isPlaying ? "Pause video" : "Play video"}
|
|
170
|
+
</button>
|
|
171
|
+
<input
|
|
172
|
+
type="range"
|
|
173
|
+
min={0}
|
|
174
|
+
max={duration || 0}
|
|
175
|
+
step={0.1}
|
|
176
|
+
value={Math.min(currentTime, duration || 0)}
|
|
177
|
+
onChange={(event) => seekPreview(Number(event.target.value))}
|
|
178
|
+
aria-label="Seek video"
|
|
179
|
+
disabled={!duration}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
{video.caption ? <figcaption>{video.caption}</figcaption> : null}
|
|
183
|
+
{video.credit ? <p className="article-video__credit">{video.credit}</p> : null}
|
|
184
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
185
|
+
{video.alt}
|
|
186
|
+
</span>
|
|
187
|
+
</figure>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (isFramed) {
|
|
192
|
+
return (
|
|
193
|
+
<figure
|
|
194
|
+
className={`${figureClassName} article-video--framed`}
|
|
195
|
+
data-media-type="video"
|
|
196
|
+
data-video-theme={resolvedTheme}
|
|
197
|
+
data-video-mode={videoMode}
|
|
198
|
+
>
|
|
199
|
+
<div className="article-video__media">
|
|
200
|
+
<video
|
|
201
|
+
ref={videoRef}
|
|
202
|
+
playsInline
|
|
203
|
+
preload="metadata"
|
|
204
|
+
poster={video.posterSrc}
|
|
205
|
+
aria-label={video.alt}
|
|
206
|
+
className="article-video__player"
|
|
207
|
+
key={hasHydrated ? src : "ssr"}
|
|
208
|
+
onPlay={() => setIsPlaying(true)}
|
|
209
|
+
onPause={() => setIsPlaying(false)}
|
|
210
|
+
onEnded={() => setIsPlaying(false)}
|
|
211
|
+
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
|
212
|
+
onDurationChange={(event) => {
|
|
213
|
+
const nextDuration = event.currentTarget.duration;
|
|
214
|
+
if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<source src={src} type="video/mp4" />
|
|
218
|
+
</video>
|
|
219
|
+
</div>
|
|
220
|
+
{framedControls}
|
|
221
|
+
<span className="sr-only" data-video-slug={slug}>
|
|
222
|
+
{video.alt}
|
|
223
|
+
</span>
|
|
224
|
+
</figure>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
26
227
|
|
|
27
228
|
return (
|
|
28
|
-
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme}>
|
|
229
|
+
<figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
|
|
29
230
|
<video
|
|
30
231
|
controls
|
|
31
232
|
playsInline
|
|
@@ -45,3 +246,46 @@ export function ArticleVideoFigure({ video, slug, figureClassName = "article-pho
|
|
|
45
246
|
</figure>
|
|
46
247
|
);
|
|
47
248
|
}
|
|
249
|
+
|
|
250
|
+
function FramedVideoControls({
|
|
251
|
+
currentTime,
|
|
252
|
+
duration,
|
|
253
|
+
isPlaying,
|
|
254
|
+
onSeek,
|
|
255
|
+
onToggle,
|
|
256
|
+
}: {
|
|
257
|
+
currentTime: number;
|
|
258
|
+
duration: number;
|
|
259
|
+
isPlaying: boolean;
|
|
260
|
+
onSeek: (nextTime: number) => void;
|
|
261
|
+
onToggle: () => void;
|
|
262
|
+
}) {
|
|
263
|
+
const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
264
|
+
return (
|
|
265
|
+
<div className="article-video__cta" data-playing={isPlaying ? "true" : "false"}>
|
|
266
|
+
<button type="button" className="article-video__cta-toggle" onClick={onToggle} aria-pressed={isPlaying}>
|
|
267
|
+
<span className="article-video__cta-label">
|
|
268
|
+
<span className="article-video__cta-label-sizer" aria-hidden="true">
|
|
269
|
+
Pause Video
|
|
270
|
+
</span>
|
|
271
|
+
<span className="article-video__cta-label-text">{isPlaying ? "Pause Video" : "Play Video"}</span>
|
|
272
|
+
</span>
|
|
273
|
+
<span className="article-video__cta-icon" aria-hidden="true">
|
|
274
|
+
{isPlaying ? <Pause /> : <Play />}
|
|
275
|
+
</span>
|
|
276
|
+
</button>
|
|
277
|
+
<input
|
|
278
|
+
className="article-video__cta-seek"
|
|
279
|
+
type="range"
|
|
280
|
+
min={0}
|
|
281
|
+
max={duration || 0}
|
|
282
|
+
step={0.1}
|
|
283
|
+
value={Math.min(currentTime, duration || 0)}
|
|
284
|
+
onChange={(event) => onSeek(Number(event.target.value))}
|
|
285
|
+
aria-label="Seek video"
|
|
286
|
+
disabled={!duration}
|
|
287
|
+
style={{ "--seek-progress": `${seekProgress}%` } as CSSProperties}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
import type { RefObject } from "react";
|
|
4
4
|
|
|
5
|
+
/** A rectangle, in the background layer's own coordinates, that header text occupies. */
|
|
6
|
+
export type BlogHeaderObstacle = {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Vertical rhythm grid the rhythm blog layout is built on. */
|
|
14
|
+
export type BlogBackgroundRhythm = {
|
|
15
|
+
rowHeight: number;
|
|
16
|
+
paintBuffer: number;
|
|
17
|
+
paintHeight: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
5
20
|
export type BlogPageBackgroundProps = {
|
|
6
21
|
pageRef: RefObject<HTMLElement | null>;
|
|
22
|
+
/** Rhythm blog layout only: header text boxes the artwork should keep clear of. */
|
|
23
|
+
headerObstacles?: BlogHeaderObstacle[];
|
|
24
|
+
/** Rhythm blog layout only: the rhythm grid, so artwork can align to it. */
|
|
25
|
+
rhythm?: BlogBackgroundRhythm;
|
|
7
26
|
};
|
|
8
27
|
|
|
9
28
|
/**
|
|
10
29
|
* Default (no-op) blog page background for publications without a bespoke
|
|
11
|
-
* backdrop.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `renderers/pretext` falls back to
|
|
15
|
-
* Intelligence, so the blog layout loads cleanly without depending on TI's
|
|
16
|
-
* package.
|
|
30
|
+
* backdrop. A publication supplies header artwork through the
|
|
31
|
+
* `components.BlogPageBackground` brand slot; with the rhythm blog layout the
|
|
32
|
+
* slot also receives `headerObstacles` and `rhythm`. This is what
|
|
33
|
+
* `renderers/pretext` falls back to when no slot component is registered.
|
|
17
34
|
*/
|
|
18
35
|
export function BlogPageBackground(_props: BlogPageBackgroundProps) {
|
|
19
36
|
return null;
|