@anthusai/papyrus 1.0.0-next.4 → 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.
@@ -0,0 +1,98 @@
1
+ import { cookies } from "next/headers";
2
+ import { getUrl } from "aws-amplify/storage/server";
3
+ import { getAmplifyServerRuntime } from "./amplify-server-runtime";
4
+ import { getSiteEnv } from "./site-env";
5
+
6
+ export const PREVIEW_ROUTE_PREFIX = "/__preview";
7
+ export const PREVIEW_KEY_PREFIX = "preview/";
8
+
9
+ const PRESIGN_SECONDS = 60;
10
+ const NOT_FOUND_ERROR_NAMES = ["NotFound", "NoSuchKey"];
11
+ const PREVIEW_EXCLUDED_PREFIXES = ["/newsroom", "/api", "/_next", PREVIEW_ROUTE_PREFIX];
12
+
13
+ export function isStaticPreviewEnabled(environment: Record<string, string | undefined> = process.env): boolean {
14
+ return getSiteEnv(environment) === "staging" && (environment.PAPYRUS_STAGING_PREVIEW ?? "").trim() === "static";
15
+ }
16
+
17
+ export function previewCandidateKeys(requestPath: string): string[] | null {
18
+ if (requestPath.includes("//")) return null;
19
+ let decoded: string;
20
+ try {
21
+ decoded = decodeURIComponent(requestPath);
22
+ } catch {
23
+ return null;
24
+ }
25
+ if (decoded.includes("//") || decoded.includes("\\") || decoded.includes("\0")) return null;
26
+ const segments = decoded.split("/").filter((segment) => segment !== "");
27
+ if (segments.some((segment) => segment === ".." || segment === ".")) return null;
28
+ if (segments.length === 0) return [`${PREVIEW_KEY_PREFIX}index.html`];
29
+ const joined = segments.join("/");
30
+ if (decoded.endsWith("/")) return [`${PREVIEW_KEY_PREFIX}${joined}/index.html`];
31
+ const base = `${PREVIEW_KEY_PREFIX}${joined}`;
32
+ return [base, `${base}.html`, `${base}/index.html`];
33
+ }
34
+
35
+ export function shouldRewriteToPreview(pathname: string): boolean {
36
+ if (pathname === "/favicon.ico" || pathname === "/robots.txt" || pathname.startsWith("/icon")) return false;
37
+ return !PREVIEW_EXCLUDED_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
38
+ }
39
+
40
+ export function isPreviewGatedPath(pathname: string): boolean {
41
+ return shouldRewriteToPreview(pathname) || pathname === PREVIEW_ROUTE_PREFIX || pathname.startsWith(`${PREVIEW_ROUTE_PREFIX}/`);
42
+ }
43
+
44
+ export function previewRewritePathname(pathname: string): string {
45
+ return `${PREVIEW_ROUTE_PREFIX}${pathname}`;
46
+ }
47
+
48
+ export function previewRequestPath(pathname: string): string {
49
+ return pathname.slice(PREVIEW_ROUTE_PREFIX.length) || "/";
50
+ }
51
+
52
+ function isNotFoundError(error: unknown): boolean {
53
+ const name = (error as { name?: string } | null)?.name ?? "";
54
+ return NOT_FOUND_ERROR_NAMES.includes(name);
55
+ }
56
+
57
+ async function presignFirstExistingKey(candidateKeys: string[]): Promise<URL | null> {
58
+ const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
59
+ return runWithAmplifyServerContext({
60
+ nextServerContext: { cookies },
61
+ operation: async (contextSpec) => {
62
+ for (const key of candidateKeys) {
63
+ try {
64
+ const { url } = await getUrl(contextSpec, {
65
+ path: key,
66
+ options: { validateObjectExistence: true, expiresIn: PRESIGN_SECONDS },
67
+ });
68
+ return url;
69
+ } catch (error) {
70
+ if (!isNotFoundError(error)) throw error;
71
+ }
72
+ }
73
+ return null;
74
+ },
75
+ });
76
+ }
77
+
78
+ function notFound(): Response {
79
+ return new Response("Not found", { status: 404, headers: { "Cache-Control": "private, no-store" } });
80
+ }
81
+
82
+ export async function streamPreviewObject(pathname: string): Promise<Response> {
83
+ if (!isStaticPreviewEnabled()) return notFound();
84
+ const candidateKeys = previewCandidateKeys(previewRequestPath(pathname));
85
+ if (!candidateKeys) return notFound();
86
+ const presigned = await presignFirstExistingKey(candidateKeys);
87
+ if (!presigned) return notFound();
88
+ const upstream = await fetch(presigned);
89
+ if (!upstream.ok || !upstream.body) return notFound();
90
+ return new Response(upstream.body, {
91
+ status: 200,
92
+ headers: {
93
+ "Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream",
94
+ "Cache-Control": "private, no-store",
95
+ "X-Robots-Tag": "noindex, nofollow",
96
+ },
97
+ });
98
+ }
@@ -0,0 +1,42 @@
1
+ export type VideoMode = "preview" | "mp4";
2
+
3
+ function parseVideoMode(value: string | null | undefined): VideoMode | null {
4
+ if (value === "preview" || value === "mp4") return value;
5
+ return null;
6
+ }
7
+
8
+ /** Server-safe mode from env only. Production always returns `"mp4"`. */
9
+ export function resolveVideoModeFromEnv(): VideoMode {
10
+ if (process.env.NODE_ENV === "production") return "mp4";
11
+ return parseVideoMode(process.env.NEXT_PUBLIC_PAPYRUS_VIDEO_MODE) ?? "preview";
12
+ }
13
+
14
+ /** Client-only `?video=` override. Returns null when absent or in production. */
15
+ export function resolveVideoModeFromSearch(search: string): VideoMode | null {
16
+ if (process.env.NODE_ENV === "production") return null;
17
+ const query = search.startsWith("?") ? search.slice(1) : search;
18
+ return parseVideoMode(new URLSearchParams(query).get("video"));
19
+ }
20
+
21
+ /**
22
+ * Effective video mode for the reader.
23
+ * Production always uses `"mp4"`. In development, `?video=` overrides
24
+ * `NEXT_PUBLIC_PAPYRUS_VIDEO_MODE` (default `"preview"`).
25
+ */
26
+ export function resolveVideoMode(): VideoMode {
27
+ if (process.env.NODE_ENV === "production") return "mp4";
28
+ if (typeof window !== "undefined") {
29
+ const fromSearch = resolveVideoModeFromSearch(window.location.search);
30
+ if (fromSearch) return fromSearch;
31
+ }
32
+ return resolveVideoModeFromEnv();
33
+ }
34
+
35
+ /**
36
+ * Server-safe gate for fetching VideoML DSL scripts.
37
+ * Always true in development so `?video=preview` and `?video=mp4` can both
38
+ * work without restarting the server. Production never fetches scripts.
39
+ */
40
+ export function shouldFetchVideoScripts(): boolean {
41
+ return process.env.NODE_ENV === "development";
42
+ }
@@ -0,0 +1,63 @@
1
+ export type VideoElementAliases = Record<string, string>;
2
+
3
+ /**
4
+ * Rewrites generic VideoML element names in a stored script to the publication's
5
+ * own scene components, e.g. `{ "quote-card": "acme-quote-card" }`.
6
+ */
7
+ export function normalizeDevPreviewDsl(dsl: string, elementAliases: VideoElementAliases = {}): string {
8
+ let normalized = dsl;
9
+ for (const [generic, branded] of Object.entries(elementAliases)) {
10
+ const escaped = generic.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ normalized = normalized.replace(new RegExp(`(</?)${escaped}(?=[\\s/>])`, "g"), `$1${branded}`);
12
+ }
13
+ return normalized;
14
+ }
15
+
16
+ export type VideoScriptTargetKind = "article" | "edition";
17
+
18
+ export type VideoScriptRef = {
19
+ slug: string;
20
+ dsl: string;
21
+ targetKind: VideoScriptTargetKind;
22
+ };
23
+
24
+ export function videomlItemSlug(targetSlug: string): string {
25
+ return `${targetSlug}--videoml`;
26
+ }
27
+
28
+ export function parseVideoScriptRef(item: {
29
+ slug: string;
30
+ editorial?: unknown;
31
+ }): VideoScriptRef | null {
32
+ const editorial = parseEditorial(item.editorial);
33
+ const videoScript = editorial?.videoScript;
34
+ if (!videoScript || typeof videoScript !== "object" || Array.isArray(videoScript)) return null;
35
+
36
+ const scriptRecord = videoScript as Record<string, unknown>;
37
+ const dsl = typeof scriptRecord.dsl === "string" ? scriptRecord.dsl.trim() : "";
38
+ if (!dsl) return null;
39
+
40
+ const target = scriptRecord.target;
41
+ const targetKind: VideoScriptTargetKind =
42
+ target && typeof target === "object" && !Array.isArray(target) && (target as Record<string, unknown>).kind === "edition"
43
+ ? "edition"
44
+ : "article";
45
+
46
+ return { slug: item.slug, dsl, targetKind };
47
+ }
48
+
49
+ function parseEditorial(editorial: unknown): Record<string, unknown> | null {
50
+ if (!editorial) return null;
51
+ if (typeof editorial === "string") {
52
+ try {
53
+ const parsed = JSON.parse(editorial) as unknown;
54
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+ if (typeof editorial === "object" && !Array.isArray(editorial)) {
60
+ return editorial as Record<string, unknown>;
61
+ }
62
+ return null;
63
+ }
package/middleware.ts CHANGED
@@ -3,6 +3,12 @@ import { NextResponse } from "next/server";
3
3
  import { getSiteBrand } from "./lib/site-brand";
4
4
  import { getSiteEnv, isIndexable } from "./lib/site-env";
5
5
  import { getStagingAccess } from "./lib/staging-gate";
6
+ import {
7
+ isPreviewGatedPath,
8
+ isStaticPreviewEnabled,
9
+ previewRewritePathname,
10
+ shouldRewriteToPreview,
11
+ } from "./lib/staging-preview-object";
6
12
 
7
13
  function usesNewsroomRootPaths(): boolean {
8
14
  const brand = getSiteBrand();
@@ -27,6 +33,11 @@ function isStagingGatedPath(pathname: string): boolean {
27
33
  }
28
34
 
29
35
  function routeRequest(request: NextRequest): NextResponse {
36
+ if (isStaticPreviewEnabled() && shouldRewriteToPreview(request.nextUrl.pathname)) {
37
+ const rewriteUrl = request.nextUrl.clone();
38
+ rewriteUrl.pathname = previewRewritePathname(request.nextUrl.pathname);
39
+ return NextResponse.rewrite(rewriteUrl);
40
+ }
30
41
  if (!usesNewsroomRootPaths()) {
31
42
  return NextResponse.next();
32
43
  }
@@ -59,7 +70,9 @@ function routeRequest(request: NextRequest): NextResponse {
59
70
 
60
71
  export async function middleware(request: NextRequest): Promise<NextResponse> {
61
72
  let sessionResponse: NextResponse | null = null;
62
- if (getSiteEnv() === "staging" && isStagingGatedPath(request.nextUrl.pathname)) {
73
+ const { pathname } = request.nextUrl;
74
+ const gated = isStaticPreviewEnabled() ? isPreviewGatedPath(pathname) : isStagingGatedPath(pathname);
75
+ if (getSiteEnv() === "staging" && gated) {
63
76
  sessionResponse = NextResponse.next();
64
77
  const access = await getStagingAccess(request, sessionResponse);
65
78
  if (access === "anonymous") {
package/package.json CHANGED
@@ -187,14 +187,18 @@
187
187
  "./lib/site-renderer": "./lib/site-renderer.ts",
188
188
  "./lib/site-stack": "./lib/site-stack.ts",
189
189
  "./lib/staging-gate": "./lib/staging-gate.ts",
190
+ "./lib/staging-preview-object": "./lib/staging-preview-object.ts",
190
191
  "./lib/themed-image": "./lib/themed-image.ts",
191
192
  "./lib/utils": "./lib/utils.ts",
193
+ "./lib/video-mode": "./lib/video-mode.ts",
194
+ "./lib/video-script": "./lib/video-script.ts",
192
195
  "./renderers/markus/index": "./renderers/markus/index.ts",
193
196
  "./renderers/markus/stubs": "./renderers/markus/stubs.tsx",
194
197
  "./renderers/pretext/article-page": "./renderers/pretext/article-page.tsx",
195
198
  "./renderers/pretext/index": "./renderers/pretext/index.ts",
196
199
  "./renderers/pretext/layouts": "./renderers/pretext/layouts.ts",
197
200
  "./renderers/pretext/presentation-shell": "./renderers/pretext/presentation-shell.tsx",
201
+ "./app/%5F_preview/[[...path]]/route": "./app/%5F_preview/[[...path]]/route.ts",
198
202
  "./app/[year]/[month]/[day]/[articleSlug]/page": "./app/[year]/[month]/[day]/[articleSlug]/page.tsx",
199
203
  "./app/[year]/[month]/[day]/page": "./app/[year]/[month]/[day]/page.tsx",
200
204
  "./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
@@ -261,7 +265,7 @@
261
265
  "optional": true
262
266
  }
263
267
  },
264
- "version": "1.0.0-next.4",
268
+ "version": "1.0.0-next.5",
265
269
  "dependencies": {
266
270
  "@aws-amplify/adapter-nextjs": "^1.7.3",
267
271
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -283,6 +287,7 @@
283
287
  "class-variance-authority": "^0.7.1",
284
288
  "clsx": "^2.1.1",
285
289
  "cmdk": "^1.1.1",
290
+ "esbuild": "^0.28.2",
286
291
  "gray-matter": "^4.0.3",
287
292
  "gsap": "^3.15.0",
288
293
  "katex": "^0.17.0",
@@ -293,6 +298,7 @@
293
298
  "streamdown": "^2.5.0",
294
299
  "tailwind-merge": "^3.6.0",
295
300
  "tailwindcss": "^4.3.0",
301
+ "tsx": "^4.23.15",
296
302
  "tw-animate-css": "^1.4.0",
297
303
  "use-stick-to-bottom": "^1.1.4",
298
304
  "yaml": "^2.9.0",
@@ -1,6 +1,16 @@
1
1
  {
2
- "version": "1.0.0-next.4",
2
+ "version": "1.0.0-next.5",
3
3
  "routes": [
4
+ {
5
+ "file": "app/%5F_preview/[[...path]]/route.ts",
6
+ "hasDefault": false,
7
+ "exports": [
8
+ "GET"
9
+ ],
10
+ "config": {
11
+ "dynamic": "\"force-dynamic\""
12
+ }
13
+ },
4
14
  {
5
15
  "file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
6
16
  "hasDefault": true,
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { bundleVideoEntries, loadBrandVideoSlot, standardEntryPath } from "./bundle-common.mjs";
5
+
6
+ export async function buildBrowserBundle(projectRoot = process.cwd(), env = process.env) {
7
+ const { brandId, bundleEntry } = await loadBrandVideoSlot(projectRoot, env);
8
+ const result = await bundleVideoEntries({
9
+ projectRoot,
10
+ entries: [standardEntryPath, bundleEntry],
11
+ outfile: path.join(projectRoot, "public/videoml/browser-bundle.js"),
12
+ });
13
+ console.error(`VideoML browser bundle for '${brandId}': ${result.outfile} (${result.size} bytes)`);
14
+ return result;
15
+ }
16
+
17
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
18
+ buildBrowserBundle().catch((error) => {
19
+ console.error(error.message);
20
+ process.exit(1);
21
+ });
22
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ bundleVideoEntries,
7
+ loadBrandVideoSlot,
8
+ previewEntryPath,
9
+ previewHtmlTemplatePath,
10
+ standardEntryPath,
11
+ } from "./bundle-common.mjs";
12
+
13
+ const REACT_18_VERSION = "18.3.1";
14
+ const VENDOR_FILES = {
15
+ "react.production.min.js": `https://unpkg.com/react@${REACT_18_VERSION}/umd/react.production.min.js`,
16
+ "react-dom.production.min.js": `https://unpkg.com/react-dom@${REACT_18_VERSION}/umd/react-dom.production.min.js`,
17
+ };
18
+
19
+ async function ensureReactVendor(outDirectory) {
20
+ const vendorDirectory = path.join(outDirectory, "vendor");
21
+ fs.mkdirSync(vendorDirectory, { recursive: true });
22
+ for (const [name, url] of Object.entries(VENDOR_FILES)) {
23
+ const target = path.join(vendorDirectory, name);
24
+ if (fs.existsSync(target)) continue;
25
+ const response = await fetch(url);
26
+ if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
27
+ fs.writeFileSync(target, Buffer.from(await response.arrayBuffer()));
28
+ }
29
+ }
30
+
31
+ export async function buildPreviewBundle(projectRoot = process.cwd(), env = process.env) {
32
+ const { brandId, bundleEntry } = await loadBrandVideoSlot(projectRoot, env);
33
+ const outDirectory = path.join(projectRoot, "public/videoml");
34
+ await ensureReactVendor(outDirectory);
35
+ fs.copyFileSync(previewHtmlTemplatePath, path.join(outDirectory, "preview.html"));
36
+ const result = await bundleVideoEntries({
37
+ projectRoot,
38
+ entries: [standardEntryPath, previewEntryPath, bundleEntry],
39
+ outfile: path.join(outDirectory, "preview-bundle.js"),
40
+ });
41
+ console.error(`VideoML preview bundle for '${brandId}': ${result.outfile} (${result.size} bytes)`);
42
+ return result;
43
+ }
44
+
45
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
46
+ buildPreviewBundle().catch((error) => {
47
+ console.error(error.message);
48
+ process.exit(1);
49
+ });
50
+ }
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { createRequire } from "node:module";
5
+
6
+ const scriptsDirectory = path.dirname(fileURLToPath(import.meta.url));
7
+ const require = createRequire(import.meta.url);
8
+
9
+ const OPTIONAL_DYNAMIC_IMPORTS_OF_VIDEOML = ["p5", "lottie-web", "animejs"];
10
+
11
+ const REACT_GLOBALS_JSX_RUNTIME = `
12
+ var React = window.React;
13
+ function jsx(type, config, maybeKey) {
14
+ var props = config || {};
15
+ var key = maybeKey !== undefined ? maybeKey : props.key;
16
+ if (key !== undefined) {
17
+ props = Object.assign({}, props, { key: key });
18
+ }
19
+ return React.createElement(type, props);
20
+ }
21
+ exports.Fragment = React.Fragment;
22
+ exports.jsx = jsx;
23
+ exports.jsxs = jsx;
24
+ `;
25
+
26
+ const reactGlobalsPlugin = {
27
+ name: "react-globals",
28
+ setup(build) {
29
+ const globals = {
30
+ react: "module.exports = window.React",
31
+ "react-dom": "module.exports = window.ReactDOM",
32
+ "react-dom/client": "module.exports = window.ReactDOM",
33
+ "react/jsx-runtime": REACT_GLOBALS_JSX_RUNTIME,
34
+ "react/jsx-dev-runtime": REACT_GLOBALS_JSX_RUNTIME,
35
+ };
36
+ build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) =>
37
+ args.path in globals ? { path: args.path, namespace: "react-globals" } : undefined,
38
+ );
39
+ build.onLoad({ filter: /.*/, namespace: "react-globals" }, (args) => ({
40
+ contents: globals[args.path],
41
+ loader: "js",
42
+ }));
43
+ },
44
+ };
45
+
46
+ export function resolveSelectedBrandId(site, env) {
47
+ const requested = (env.PAPYRUS_SITE_BRAND ?? "").trim();
48
+ return requested || site.defaultBrand || "papyrus";
49
+ }
50
+
51
+ /**
52
+ * Loads the publication's `papyrus.config.ts` from the project root and returns the
53
+ * selected brand's `video` slot. Fails with a message naming the missing piece.
54
+ */
55
+ export async function loadBrandVideoSlot(projectRoot, env = process.env) {
56
+ const configPath = path.join(projectRoot, "papyrus.config.ts");
57
+ if (!fs.existsSync(configPath)) {
58
+ throw new Error(`No papyrus.config.ts in ${projectRoot}. Run the VideoML bundle builder from the publication root.`);
59
+ }
60
+ const { tsImport } = await import("tsx/esm/api");
61
+ // Inside a Papyrus checkout, brand components import lib/site-brand, which imports this
62
+ // config: load site-brand first so the cycle resolves in the same order as in the app.
63
+ const checkoutSiteBrand = path.join(projectRoot, "lib/site-brand.ts");
64
+ if (fs.existsSync(checkoutSiteBrand)) await tsImport(pathToFileURL(checkoutSiteBrand).href, import.meta.url);
65
+ const loaded = await tsImport(pathToFileURL(configPath).href, import.meta.url);
66
+ const exported = loaded.default ?? loaded;
67
+ const site = exported.brands === undefined && exported.default ? exported.default : exported;
68
+ const brandId = resolveSelectedBrandId(site, env);
69
+ const brand = (site.brands ?? []).find((candidate) => candidate.id === brandId);
70
+ if (!brand) {
71
+ const registered = (site.brands ?? []).map((candidate) => candidate.id).join(", ") || "none";
72
+ throw new Error(`Brand '${brandId}' is not registered in papyrus.config.ts (registered: ${registered}).`);
73
+ }
74
+ if (!brand.video || typeof brand.video.bundleEntry !== "string" || !brand.video.bundleEntry) {
75
+ throw new Error(
76
+ `Brand '${brandId}' has no brand.video.bundleEntry in papyrus.config.ts. ` +
77
+ "Set brand.video = { bundleEntry: \"<path to a TSX module that registers the brand's VideoML scene components>\" }.",
78
+ );
79
+ }
80
+ const bundleEntry = path.resolve(projectRoot, brand.video.bundleEntry);
81
+ if (!fs.existsSync(bundleEntry)) {
82
+ throw new Error(`brand.video.bundleEntry for '${brandId}' does not exist: ${bundleEntry}`);
83
+ }
84
+ return { brandId, bundleEntry, video: brand.video };
85
+ }
86
+
87
+ function resolveEsbuild(projectRoot) {
88
+ for (const base of [projectRoot, scriptsDirectory]) {
89
+ try {
90
+ return require(require.resolve("esbuild", { paths: [base] }));
91
+ } catch {
92
+ continue;
93
+ }
94
+ }
95
+ throw new Error("esbuild not found. Install it in the publication (npm install --save-dev esbuild).");
96
+ }
97
+
98
+ /**
99
+ * Bundles [standard entry, optional extra entries, brand entry] into one IIFE.
100
+ * React comes from `window.React` / `window.ReactDOM` (the render shell loads React 18 UMD).
101
+ */
102
+ export async function bundleVideoEntries({ projectRoot, entries, outfile }) {
103
+ const esbuild = resolveEsbuild(projectRoot);
104
+ fs.mkdirSync(path.dirname(outfile), { recursive: true });
105
+ const importLines = entries.map((entry) => `import ${JSON.stringify(entry)};`).join("\n");
106
+ await esbuild.build({
107
+ stdin: { contents: importLines, resolveDir: projectRoot, sourcefile: "papyrus-videoml-entry.ts", loader: "ts" },
108
+ bundle: true,
109
+ format: "iife",
110
+ platform: "browser",
111
+ jsx: "automatic",
112
+ outfile,
113
+ absWorkingDir: projectRoot,
114
+ nodePaths: [path.join(projectRoot, "node_modules")],
115
+ external: OPTIONAL_DYNAMIC_IMPORTS_OF_VIDEOML,
116
+ plugins: [reactGlobalsPlugin],
117
+ loader: { ".tsx": "tsx", ".ts": "ts" },
118
+ define: { "process.env.NODE_ENV": '"production"' },
119
+ sourcemap: true,
120
+ logLevel: "warning",
121
+ });
122
+ const size = fs.statSync(outfile).size;
123
+ if (size === 0) throw new Error(`VideoML bundle is empty: ${outfile}`);
124
+ return { outfile, size };
125
+ }
126
+
127
+ export const standardEntryPath = path.join(scriptsDirectory, "standard-entry.tsx");
128
+ export const previewEntryPath = path.join(scriptsDirectory, "preview/preview-entry.tsx");
129
+ export const previewHtmlTemplatePath = path.join(scriptsDirectory, "preview.html");