pi-studio 0.9.40 → 0.9.42

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/index.ts CHANGED
@@ -29,8 +29,15 @@ import {
29
29
  } from "./shared/studio-markdown-latex-literals.js";
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
+ import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
32
33
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
33
34
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
35
+ import {
36
+ buildStudioPendingPage,
37
+ buildStudioPendingSecurityHeaders,
38
+ isValidStudioLaunchId,
39
+ normalizeStudioPendingKind,
40
+ } from "./shared/studio-tab-launcher.js";
34
41
  import { renderStudioAnnotationInlineHtml } from "./shared/studio-annotation-render.js";
35
42
  import {
36
43
  buildStudioMermaidCliIconArgs,
@@ -90,6 +97,7 @@ interface StudioQuartoPreviewState {
90
97
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
91
98
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
92
99
  const STUDIO_MERMAID_HELPERS_URL = new URL("./client/studio-mermaid-helpers.js", import.meta.url);
100
+ const STUDIO_NAVIGATION_HELPERS_URL = new URL("./client/studio-navigation-helpers.js", import.meta.url);
93
101
  const STUDIO_CLIENT_URL = new URL("./client/studio-client.js", import.meta.url);
94
102
 
95
103
  interface StudioServerState {
@@ -7259,6 +7267,17 @@ function respondText(res: ServerResponse, status: number, text: string): void {
7259
7267
  res.end(text);
7260
7268
  }
7261
7269
 
7270
+ function respondStudioPendingError(res: ServerResponse, status: number, text: string, allow?: string): void {
7271
+ const nonce = randomUUID().replace(/-/g, "");
7272
+ const headers = {
7273
+ ...buildStudioPendingSecurityHeaders(nonce),
7274
+ "Content-Type": "text/plain; charset=utf-8",
7275
+ ...(allow ? { Allow: allow } : {}),
7276
+ };
7277
+ res.writeHead(status, headers);
7278
+ res.end(text);
7279
+ }
7280
+
7262
7281
  function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
7263
7282
  const method = (req.method ?? "GET").toUpperCase();
7264
7283
  if (method !== "GET" && method !== "HEAD") {
@@ -7394,12 +7413,9 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7394
7413
  }
7395
7414
  const document = buildStudioLocalResourcePreviewDocument(resource);
7396
7415
  const docId = storeTransientStudioDocument(document);
7397
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7398
- const parsedUrl = new URL(url);
7399
7416
  respondJson(res, 200, {
7400
7417
  ...basePayload,
7401
- url,
7402
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7418
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7403
7419
  });
7404
7420
  return;
7405
7421
  }
@@ -7459,13 +7475,10 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7459
7475
  }
7460
7476
 
7461
7477
  const docId = storeTransientStudioDocument(document);
7462
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
7463
- const parsedUrl = new URL(url);
7464
7478
  respondJson(res, 200, {
7465
7479
  ...basePayload,
7466
7480
  converted,
7467
- url,
7468
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
7481
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7469
7482
  });
7470
7483
  }
7471
7484
 
@@ -7532,27 +7545,6 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7532
7545
  }
7533
7546
  }
7534
7547
 
7535
- function openUrlInDefaultBrowser(url: string): Promise<void> {
7536
- const openCommand =
7537
- process.platform === "darwin"
7538
- ? { command: "open", args: [url] }
7539
- : process.platform === "win32"
7540
- ? { command: "cmd", args: ["/c", "start", "", url] }
7541
- : { command: "xdg-open", args: [url] };
7542
-
7543
- return new Promise<void>((resolve, reject) => {
7544
- const child = spawn(openCommand.command, openCommand.args, {
7545
- stdio: "ignore",
7546
- detached: true,
7547
- });
7548
- child.once("error", reject);
7549
- child.once("spawn", () => {
7550
- child.unref();
7551
- resolve();
7552
- });
7553
- });
7554
- }
7555
-
7556
7548
  function openPathInDefaultViewer(path: string): Promise<void> {
7557
7549
  const openCommand =
7558
7550
  process.platform === "darwin"
@@ -10078,8 +10070,7 @@ function readTransientStudioDocument(id: string): InitialStudioDocument | null {
10078
10070
  return entry ? { ...entry.document } : null;
10079
10071
  }
10080
10072
 
10081
- function buildStudioUrl(
10082
- port: number,
10073
+ function buildStudioRelativeUrl(
10083
10074
  token: string,
10084
10075
  mode: StudioUiMode = "full",
10085
10076
  doc?: InitialStudioDocument | null,
@@ -10095,7 +10086,18 @@ function buildStudioUrl(
10095
10086
  if (doc?.draftId) params.set("draftId", doc.draftId);
10096
10087
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
10097
10088
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
10098
- return `http://127.0.0.1:${port}/?${params.toString()}`;
10089
+ return `/?${params.toString()}`;
10090
+ }
10091
+
10092
+ function buildStudioUrl(
10093
+ port: number,
10094
+ token: string,
10095
+ mode: StudioUiMode = "full",
10096
+ doc?: InitialStudioDocument | null,
10097
+ docId?: string,
10098
+ options?: { skipWorkspaceRestore?: boolean },
10099
+ ): string {
10100
+ return `http://127.0.0.1:${port}${buildStudioRelativeUrl(token, mode, doc, docId, options)}`;
10099
10101
  }
10100
10102
 
10101
10103
  interface StudioLaunchFlags {
@@ -10509,6 +10511,7 @@ function buildStudioHtml(
10509
10511
  const stylesheetHref = `/studio.css?token=${encodeURIComponent(studioToken ?? "")}`;
10510
10512
  const annotationHelpersScriptHref = `/studio-annotation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10511
10513
  const mermaidHelpersScriptHref = `/studio-mermaid-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10514
+ const navigationHelpersScriptHref = `/studio-navigation-helpers.js?token=${encodeURIComponent(studioToken ?? "")}`;
10512
10515
  const clientScriptHref = `/studio-client.js?token=${encodeURIComponent(studioToken ?? "")}`;
10513
10516
  const faviconHref = buildStudioFaviconDataUri(style);
10514
10517
  const bootConfigJson = JSON.stringify({ mermaidConfig }).replace(/</g, "\\u003c");
@@ -10927,6 +10930,7 @@ ${cssVarsBlock}
10927
10930
  </script>
10928
10931
  <script src="${annotationHelpersScriptHref}"></script>
10929
10932
  <script src="${mermaidHelpersScriptHref}"></script>
10933
+ <script src="${navigationHelpersScriptHref}"></script>
10930
10934
  <script src="${clientScriptHref}"></script>
10931
10935
  </body>
10932
10936
  </html>`;
@@ -11304,14 +11308,7 @@ export default function (pi: ExtensionAPI) {
11304
11308
  return null;
11305
11309
  };
11306
11310
 
11307
- const isProbablyCmuxSession = (): boolean => {
11308
- const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
11309
- if (workspaceId) return true;
11310
- const termProgram = String(process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
11311
- if (termProgram === "cmux") return true;
11312
- const term = String(process.env.TERM ?? "").trim().toLowerCase();
11313
- return term.includes("cmux");
11314
- };
11311
+ const isProbablyCmuxSession = (): boolean => isStudioCmuxSession(process.env);
11315
11312
 
11316
11313
  const sanitizeTerminalNotificationText = (value: string, maxLength = 240): string => {
11317
11314
  const sanitized = String(value)
@@ -12852,13 +12849,10 @@ export default function (pi: ExtensionAPI) {
12852
12849
  resourceDir,
12853
12850
  };
12854
12851
  const docId = storeTransientStudioDocument(document);
12855
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
12856
- const parsedUrl = new URL(url);
12857
12852
  sendToClient(client, {
12858
12853
  type: "editor_only_ready",
12859
12854
  requestId: msg.requestId,
12860
- url,
12861
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
12855
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
12862
12856
  message: hasContent
12863
12857
  ? "Editor tab is ready with a detached copy of the current editor text."
12864
12858
  : "Blank editor tab is ready.",
@@ -14238,8 +14232,6 @@ export default function (pi: ExtensionAPI) {
14238
14232
  resourceDir: dirname(exportedPath),
14239
14233
  };
14240
14234
  const docId = storeTransientStudioDocument(document);
14241
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14242
- const parsedUrl = new URL(url);
14243
14235
  respondJson(res, 200, {
14244
14236
  ok: true,
14245
14237
  filename,
@@ -14247,8 +14239,7 @@ export default function (pi: ExtensionAPI) {
14247
14239
  writeError: writeResult.error,
14248
14240
  warning: warning ?? null,
14249
14241
  openedStudio: true,
14250
- url,
14251
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14242
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14252
14243
  downloadUrl: `/export-pdf?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14253
14244
  });
14254
14245
  return;
@@ -14385,8 +14376,6 @@ export default function (pi: ExtensionAPI) {
14385
14376
  draftId: exportedPath ? undefined : createStudioDraftId(),
14386
14377
  };
14387
14378
  const docId = storeTransientStudioDocument(document);
14388
- const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true });
14389
- const parsedUrl = new URL(url);
14390
14379
  respondJson(res, 200, {
14391
14380
  ok: true,
14392
14381
  filename,
@@ -14394,8 +14383,7 @@ export default function (pi: ExtensionAPI) {
14394
14383
  writeError: writeResult.error,
14395
14384
  warning: warning ?? null,
14396
14385
  openedStudio: true,
14397
- url,
14398
- relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
14386
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
14399
14387
  downloadUrl: `/export-html?token=${encodeURIComponent(token)}&id=${encodeURIComponent(exportId)}`,
14400
14388
  });
14401
14389
  return;
@@ -14454,6 +14442,39 @@ export default function (pi: ExtensionAPI) {
14454
14442
  return;
14455
14443
  }
14456
14444
 
14445
+ if (requestUrl.pathname === "/studio-open-pending") {
14446
+ const tokens = requestUrl.searchParams.getAll("token");
14447
+ if (tokens.length !== 1 || tokens[0] !== serverState.token) {
14448
+ respondStudioPendingError(res, 403, "Invalid or expired studio token. Re-run /studio.");
14449
+ return;
14450
+ }
14451
+
14452
+ const method = (req.method ?? "GET").toUpperCase();
14453
+ if (method !== "GET") {
14454
+ respondStudioPendingError(res, 405, "Method not allowed. Use GET.", "GET");
14455
+ return;
14456
+ }
14457
+
14458
+ const allowedKeys = new Set(["token", "launchId", "kind"]);
14459
+ if (Array.from(requestUrl.searchParams.keys()).some((key) => !allowedKeys.has(key))) {
14460
+ respondStudioPendingError(res, 400, "Unsupported Studio pending-page parameter.");
14461
+ return;
14462
+ }
14463
+ const launchIds = requestUrl.searchParams.getAll("launchId");
14464
+ const kinds = requestUrl.searchParams.getAll("kind");
14465
+ const launchId = launchIds.length === 1 ? launchIds[0] : "";
14466
+ const kind = kinds.length === 1 ? normalizeStudioPendingKind(kinds[0]) : null;
14467
+ if (!isValidStudioLaunchId(launchId) || !kind) {
14468
+ respondStudioPendingError(res, 400, "Invalid Studio pending-page request.");
14469
+ return;
14470
+ }
14471
+
14472
+ const nonce = randomUUID().replace(/-/g, "");
14473
+ res.writeHead(200, buildStudioPendingSecurityHeaders(nonce));
14474
+ res.end(buildStudioPendingPage({ token: serverState.token, launchId, kind, nonce }));
14475
+ return;
14476
+ }
14477
+
14457
14478
  if (requestUrl.pathname === "/studio.css") {
14458
14479
  const token = requestUrl.searchParams.get("token") ?? "";
14459
14480
  if (token !== serverState.token) {
@@ -14483,7 +14504,12 @@ export default function (pi: ExtensionAPI) {
14483
14504
  return;
14484
14505
  }
14485
14506
 
14486
- if (requestUrl.pathname === "/studio-annotation-helpers.js" || requestUrl.pathname === "/studio-mermaid-helpers.js" || requestUrl.pathname === "/studio-client.js") {
14507
+ if (
14508
+ requestUrl.pathname === "/studio-annotation-helpers.js"
14509
+ || requestUrl.pathname === "/studio-mermaid-helpers.js"
14510
+ || requestUrl.pathname === "/studio-navigation-helpers.js"
14511
+ || requestUrl.pathname === "/studio-client.js"
14512
+ ) {
14487
14513
  const token = requestUrl.searchParams.get("token") ?? "";
14488
14514
  if (token !== serverState.token) {
14489
14515
  respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
@@ -14501,12 +14527,16 @@ export default function (pi: ExtensionAPI) {
14501
14527
  ? STUDIO_ANNOTATION_HELPERS_URL
14502
14528
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14503
14529
  ? STUDIO_MERMAID_HELPERS_URL
14504
- : STUDIO_CLIENT_URL;
14530
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14531
+ ? STUDIO_NAVIGATION_HELPERS_URL
14532
+ : STUDIO_CLIENT_URL;
14505
14533
  const targetLabel = requestUrl.pathname === "/studio-annotation-helpers.js"
14506
14534
  ? "studio annotation helper script"
14507
14535
  : requestUrl.pathname === "/studio-mermaid-helpers.js"
14508
14536
  ? "studio Mermaid helper script"
14509
- : "studio client script";
14537
+ : requestUrl.pathname === "/studio-navigation-helpers.js"
14538
+ ? "studio navigation helper script"
14539
+ : "studio client script";
14510
14540
 
14511
14541
  try {
14512
14542
  const clientScript = readFileSync(targetUrl, "utf-8");
@@ -15779,7 +15809,7 @@ export default function (pi: ExtensionAPI) {
15779
15809
  const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
15780
15810
  ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
15781
15811
  } else {
15782
- await openUrlInDefaultBrowser(url);
15812
+ await openStudioUrlInBrowser(url);
15783
15813
  if (selected.source === "file") {
15784
15814
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
15785
15815
  } else if (selected.source === "last-response") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.40",
3
+ "version": "0.9.42",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "test": "node --test",
33
+ "test:browser": "node --test test/studio-tab-launcher-browser.test.js",
33
34
  "typecheck": "tsc --noEmit"
34
35
  },
35
36
  "pi": {
@@ -0,0 +1,139 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS = 5_000;
4
+
5
+ /**
6
+ * Detect whether the current process is running inside cmux.
7
+ *
8
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
9
+ * @returns {boolean}
10
+ */
11
+ export function isStudioCmuxSession(env = process.env) {
12
+ const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
13
+ const termProgram = String(env.TERM_PROGRAM ?? "").trim().toLowerCase();
14
+ const term = String(env.TERM ?? "").trim().toLowerCase();
15
+ const bundleId = String(env.CMUX_BUNDLE_ID ?? "").trim().toLowerCase();
16
+ return Boolean(workspaceId || termProgram === "cmux" || term.includes("cmux") || bundleId.includes("cmux"));
17
+ }
18
+
19
+ /**
20
+ * Build the cmux CLI invocation for opening Studio in the caller's workspace.
21
+ *
22
+ * @param {string} target
23
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
24
+ * @returns {{ command: string, args: string[] } | undefined}
25
+ */
26
+ export function getStudioCmuxBrowserOpenCommand(target, env = process.env) {
27
+ if (!isStudioCmuxSession(env)) return undefined;
28
+
29
+ const workspaceId = String(env.CMUX_WORKSPACE_ID ?? "").trim();
30
+ const command = String(env.CMUX_BUNDLED_CLI_PATH ?? "").trim() || "cmux";
31
+ const args = ["browser", "open", target];
32
+ if (workspaceId) args.push("--workspace", workspaceId);
33
+ args.push("--focus", "true");
34
+ return { command, args };
35
+ }
36
+
37
+ /**
38
+ * Build the platform-native system-browser invocation.
39
+ *
40
+ * @param {string} target
41
+ * @param {NodeJS.Platform} [platform]
42
+ * @returns {{ command: string, args: string[] }}
43
+ */
44
+ export function getStudioDefaultBrowserOpenCommand(target, platform = process.platform) {
45
+ if (platform === "darwin") return { command: "open", args: [target] };
46
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", target] };
47
+ return { command: "xdg-open", args: [target] };
48
+ }
49
+
50
+ /**
51
+ * @param {{ command: string, args: string[] }} openCommand
52
+ * @param {typeof spawn} spawnProcess
53
+ * @returns {Promise<void>}
54
+ */
55
+ function spawnDetachedBrowser(openCommand, spawnProcess) {
56
+ return new Promise((resolve, reject) => {
57
+ let child;
58
+ try {
59
+ child = spawnProcess(openCommand.command, openCommand.args, {
60
+ stdio: "ignore",
61
+ detached: true,
62
+ });
63
+ } catch (error) {
64
+ reject(error);
65
+ return;
66
+ }
67
+ child.once("error", reject);
68
+ child.once("spawn", () => {
69
+ child.unref();
70
+ resolve();
71
+ });
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Try to open Studio in a focused cmux browser surface.
77
+ *
78
+ * @param {string} target
79
+ * @param {{
80
+ * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
81
+ * spawnProcess?: typeof spawn,
82
+ * timeoutMs?: number,
83
+ * }} [options]
84
+ * @returns {Promise<boolean>}
85
+ */
86
+ export async function tryOpenStudioUrlInCmuxBrowser(target, options = {}) {
87
+ const openCommand = getStudioCmuxBrowserOpenCommand(target, options.env ?? process.env);
88
+ if (!openCommand) return false;
89
+
90
+ const spawnProcess = options.spawnProcess ?? spawn;
91
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs >= 0
92
+ ? options.timeoutMs
93
+ : STUDIO_CMUX_BROWSER_OPEN_TIMEOUT_MS;
94
+
95
+ return await new Promise((resolve) => {
96
+ let settled = false;
97
+ let child;
98
+ const finish = (opened) => {
99
+ if (settled) return;
100
+ settled = true;
101
+ clearTimeout(timeout);
102
+ resolve(opened);
103
+ };
104
+ const timeout = setTimeout(() => {
105
+ child?.kill();
106
+ finish(false);
107
+ }, timeoutMs);
108
+ timeout.unref?.();
109
+
110
+ try {
111
+ child = spawnProcess(openCommand.command, openCommand.args, { stdio: "ignore" });
112
+ } catch {
113
+ finish(false);
114
+ return;
115
+ }
116
+ child.once("error", () => finish(false));
117
+ child.once("close", (code) => finish(code === 0));
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Open Studio in cmux when available, falling back to the system browser.
123
+ *
124
+ * @param {string} target
125
+ * @param {{
126
+ * env?: NodeJS.ProcessEnv | Record<string, string | undefined>,
127
+ * platform?: NodeJS.Platform,
128
+ * spawnProcess?: typeof spawn,
129
+ * timeoutMs?: number,
130
+ * }} [options]
131
+ * @returns {Promise<"cmux" | "system">}
132
+ */
133
+ export async function openStudioUrlInBrowser(target, options = {}) {
134
+ if (await tryOpenStudioUrlInCmuxBrowser(target, options)) return "cmux";
135
+
136
+ const openCommand = getStudioDefaultBrowserOpenCommand(target, options.platform ?? process.platform);
137
+ await spawnDetachedBrowser(openCommand, options.spawnProcess ?? spawn);
138
+ return "system";
139
+ }
@@ -0,0 +1,86 @@
1
+ const STUDIO_PENDING_KINDS = Object.freeze(["document", "preview", "export"]);
2
+ const STUDIO_LAUNCH_ID_PATTERN = /^[a-zA-Z0-9_-]{20,128}$/;
3
+ const STUDIO_CSP_NONCE_PATTERN = /^[a-zA-Z0-9_-]{16,128}$/;
4
+
5
+ function escapeHtmlAttribute(value) {
6
+ return String(value ?? "")
7
+ .replace(/&/g, "&amp;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
13
+ export function normalizeStudioPendingKind(value) {
14
+ return STUDIO_PENDING_KINDS.includes(value) ? value : null;
15
+ }
16
+
17
+ export function isValidStudioLaunchId(value) {
18
+ return typeof value === "string" && STUDIO_LAUNCH_ID_PATTERN.test(value);
19
+ }
20
+
21
+ export function buildStudioPendingSecurityHeaders(nonce) {
22
+ if (typeof nonce !== "string" || !STUDIO_CSP_NONCE_PATTERN.test(nonce)) {
23
+ throw new Error("Invalid Studio pending-page CSP nonce.");
24
+ }
25
+ return {
26
+ "Content-Type": "text/html; charset=utf-8",
27
+ "Cache-Control": "no-store",
28
+ "X-Content-Type-Options": "nosniff",
29
+ "Referrer-Policy": "no-referrer",
30
+ "Cross-Origin-Opener-Policy": "same-origin",
31
+ "Cross-Origin-Resource-Policy": "same-origin",
32
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
33
+ "Content-Security-Policy": [
34
+ "default-src 'none'",
35
+ `script-src 'nonce-${nonce}'`,
36
+ `style-src 'nonce-${nonce}'`,
37
+ "base-uri 'none'",
38
+ "form-action 'none'",
39
+ "object-src 'none'",
40
+ "frame-ancestors 'none'",
41
+ ].join("; "),
42
+ };
43
+ }
44
+
45
+ export function buildStudioPendingPage(options) {
46
+ const config = options && typeof options === "object" ? options : {};
47
+ const token = typeof config.token === "string" ? config.token : "";
48
+ const launchId = typeof config.launchId === "string" ? config.launchId : "";
49
+ const kind = normalizeStudioPendingKind(config.kind);
50
+ const nonce = typeof config.nonce === "string" ? config.nonce : "";
51
+ if (!token) throw new Error("Studio pending page requires a token.");
52
+ if (!isValidStudioLaunchId(launchId)) throw new Error("Invalid Studio launch ID.");
53
+ if (!kind) throw new Error("Invalid Studio pending-page kind.");
54
+ buildStudioPendingSecurityHeaders(nonce);
55
+
56
+ const kindLabel = kind === "document" ? "document" : (kind === "export" ? "export" : "preview");
57
+ const helperQuery = new URLSearchParams({ token }).toString();
58
+ return `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8" />
62
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
63
+ <title>Preparing Studio tab…</title>
64
+ <style nonce="${escapeHtmlAttribute(nonce)}">
65
+ :root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
66
+ body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: Canvas; color: CanvasText; }
67
+ main { width: min(34rem, calc(100vw - 3rem)); padding: 2rem; border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); border-radius: 12px; }
68
+ h1 { margin: 0 0 0.65rem; font-size: 1.15rem; }
69
+ p { margin: 0; line-height: 1.5; opacity: 0.8; overflow-wrap: anywhere; }
70
+ button { margin-top: 1.25rem; padding: 0.45rem 0.8rem; font: inherit; }
71
+ [hidden] { display: none !important; }
72
+ </style>
73
+ </head>
74
+ <body data-studio-pending-launch="1" data-launch-id="${escapeHtmlAttribute(launchId)}" data-launch-kind="${escapeHtmlAttribute(kind)}" data-studio-token="${escapeHtmlAttribute(token)}">
75
+ <main aria-live="polite" aria-atomic="true">
76
+ <h1 id="pendingTitle">Preparing Studio ${kindLabel}…</h1>
77
+ <p id="pendingDetail">Waiting for the originating Studio page.</p>
78
+ <button id="pendingCloseBtn" type="button" hidden>Close tab</button>
79
+ </main>
80
+ <noscript>This Studio tab requires JavaScript. You can close it and return to the originating Studio page.</noscript>
81
+ <script nonce="${escapeHtmlAttribute(nonce)}" src="/studio-navigation-helpers.js?${escapeHtmlAttribute(helperQuery)}"></script>
82
+ </body>
83
+ </html>`;
84
+ }
85
+
86
+ export { STUDIO_PENDING_KINDS };