diff --git a/containers/gotenberg/Dockerfile b/containers/gotenberg/Dockerfile
index f5843cf4..2470b465 100644
--- a/containers/gotenberg/Dockerfile
+++ b/containers/gotenberg/Dockerfile
@@ -1,3 +1,3 @@
-FROM gotenberg/gotenberg:8.34.0-libreoffice
+FROM gotenberg/gotenberg:8.34.0
EXPOSE 3000
diff --git a/package.json b/package.json
index eddf2417..649c40b0 100644
--- a/package.json
+++ b/package.json
@@ -127,6 +127,7 @@
"partyserver": "^0.5.8",
"posthog-js": "^1.396.6",
"posthog-node": "^5.39.4",
+ "prosemirror-transform": "1.12.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.12.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3e4769c..fc6bbcbe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -284,6 +284,9 @@ importers:
posthog-node:
specifier: ^5.39.4
version: 5.39.4
+ prosemirror-transform:
+ specifier: 1.12.0
+ version: 1.12.0
react:
specifier: ^19.2.7
version: 19.2.7
diff --git a/src/features/workspaces/components/WorkspaceTopBar.tsx b/src/features/workspaces/components/WorkspaceTopBar.tsx
index f73d227d..20b3ad6f 100644
--- a/src/features/workspaces/components/WorkspaceTopBar.tsx
+++ b/src/features/workspaces/components/WorkspaceTopBar.tsx
@@ -1,5 +1,6 @@
-import { MessageSquare, Share2 } from "lucide-react";
+import { Download, MessageSquare, Share2 } from "lucide-react";
import { type ReactNode, useState } from "react";
+import { toast } from "sonner";
import UserProfileDropdown from "#/components/UserProfileDropdown";
import { Kbd } from "#/components/ui/kbd";
@@ -20,6 +21,7 @@ import {
useWorkspaceAiChatSurfaceMode,
useWorkspaceUiStore,
} from "#/features/workspaces/state/workspace-ui-store";
+import { getErrorMessage } from "#/lib/error-message";
import { formatAppHotkey, getAppHotkey } from "#/lib/hotkeys-core";
type PresenceStatus = "connecting" | "connected" | "disconnected";
@@ -61,7 +63,41 @@ export default function WorkspaceTopBar({
const chatSurfaceMode = useWorkspaceAiChatSurfaceMode(workspace.id);
const setChatSurfaceMode = useWorkspaceUiStore((state) => state.setChatSurfaceMode);
const [shareOpen, setShareOpen] = useState(false);
+ const [exporting, setExporting] = useState(false);
const aiChatHotkey = formatAppHotkey(getAppHotkey("workspace.aiChat.toggle").hotkey);
+ const handleExport = async () => {
+ if (exporting) {
+ return;
+ }
+
+ setExporting(true);
+
+ try {
+ const response = await fetch(`/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`);
+
+ if (!response.ok) {
+ throw new Error(
+ (await getExportErrorMessage(response)) ?? "Unable to export this workspace right now.",
+ );
+ }
+
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+
+ link.href = objectUrl;
+ link.download = getDownloadFileName(response.headers) ?? `${workspace.name}.zip`;
+ link.rel = "noopener";
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(objectUrl);
+ } catch (error) {
+ toast.error(getErrorMessage(error, "Unable to export this workspace right now."));
+ } finally {
+ setExporting(false);
+ }
+ };
return (
<>
@@ -75,6 +111,16 @@ export default function WorkspaceTopBar({
>
+ void handleExport()}
+ variant="outline"
+ className="border-border bg-background shadow-xs hover:bg-muted"
+ >
+
+ Export
+
{chatSurfaceMode === "hidden" ? (
@@ -126,3 +172,22 @@ export default function WorkspaceTopBar({
>
);
}
+
+function getDownloadFileName(headers: Headers) {
+ const contentDisposition = headers.get("content-disposition");
+ const match =
+ contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i) ??
+ contentDisposition?.match(/filename="([^"]+)"/i) ??
+ contentDisposition?.match(/filename=([^;]+)/i);
+
+ return match?.[1] ? decodeURIComponent(match[1].trim()) : null;
+}
+
+async function getExportErrorMessage(response: Response) {
+ try {
+ const body = (await response.json()) as { message?: unknown };
+ return typeof body.message === "string" ? body.message : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/features/workspaces/export/workspace-export.test.ts b/src/features/workspaces/export/workspace-export.test.ts
new file mode 100644
index 00000000..4dcfd7f6
--- /dev/null
+++ b/src/features/workspaces/export/workspace-export.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ WorkspaceItemSummary,
+ WorkspacePage,
+ WorkspaceSummary,
+} from "#/features/workspaces/contracts";
+import {
+ buildWorkspaceExportEntries,
+ buildWorkspaceExportPathIndex,
+ extractTextFromHtml,
+ reconstructWorkspaceState,
+ sanitizeExportName,
+} from "./workspace-export";
+import { createZipArchive } from "./workspace-zip";
+
+describe("workspace export helpers", () => {
+ it("replays events after a snapshot in chronological order", () => {
+ const page = createPage({
+ items: [
+ createItem({
+ id: "doc",
+ name: "Draft",
+ sortOrder: 1000,
+ type: "document",
+ }),
+ createItem({
+ id: "folder",
+ name: "Folder",
+ sortOrder: 2000,
+ type: "folder",
+ }),
+ ],
+ revision: 1,
+ });
+
+ const moved = createItem({
+ id: "doc",
+ name: "Final",
+ parentId: "folder",
+ sortOrder: 1000,
+ type: "document",
+ });
+ const result = reconstructWorkspaceState({
+ snapshot: page,
+ eventsAfterSnapshot: [
+ {
+ actorUserId: "user",
+ clientMutationId: null,
+ createdAt: "2026-07-28T00:00:02.000Z",
+ id: "event-2",
+ payload: { items: [moved] },
+ revision: 3,
+ type: "workspace.items.moved",
+ workspaceId: "workspace",
+ },
+ {
+ actorUserId: "user",
+ clientMutationId: null,
+ createdAt: "2026-07-28T00:00:01.000Z",
+ id: "event-1",
+ payload: {
+ item: createItem({
+ id: "doc",
+ name: "Final",
+ sortOrder: 1000,
+ type: "document",
+ }),
+ },
+ revision: 2,
+ type: "workspace.item.renamed",
+ workspaceId: "workspace",
+ },
+ ],
+ });
+
+ expect(result.revision).toBe(3);
+ expect(result.items.find((item) => item.id === "doc")).toMatchObject({
+ name: "Final",
+ parentId: "folder",
+ });
+ });
+
+ it("creates nested, collision-safe export paths", () => {
+ const paths = buildWorkspaceExportPathIndex("My Workspace", [
+ createItem({ id: "folder", name: "Biology", sortOrder: 1000, type: "folder" }),
+ createItem({
+ id: "doc-a",
+ name: "Cell Notes",
+ parentId: "folder",
+ sortOrder: 1000,
+ type: "document",
+ }),
+ createItem({
+ id: "doc-b",
+ name: "Cell Notes",
+ parentId: "folder",
+ sortOrder: 2000,
+ type: "document",
+ }),
+ createItem({
+ id: "unsafe",
+ name: "../bad:name*",
+ parentId: "folder",
+ sortOrder: 3000,
+ type: "file",
+ }),
+ ]);
+
+ expect(paths.get("doc-a")).toBe("My Workspace/Biology/Cell Notes");
+ expect(paths.get("doc-b")).toBe("My Workspace/Biology/Cell Notes (1)");
+ expect(paths.get("unsafe")).toBe("My Workspace/Biology/_bad_name_");
+ });
+
+ it("sanitizes empty and unsafe names", () => {
+ expect(sanitizeExportName("")).toBe("Untitled");
+ expect(sanitizeExportName("..")).toBe("Untitled");
+ expect(sanitizeExportName('Chapter: 1 / "Draft"')).toBe("Chapter_ 1 _ _Draft_");
+ });
+
+ it("represents empty folders in the zip", () => {
+ const zip = createZipArchive([{ path: "Workspace/" }, { path: "Workspace/Empty/" }]);
+ const text = new TextDecoder().decode(zip);
+
+ expect(text).toContain("Workspace/Empty/");
+ expect(zip[0]).toBe(0x50);
+ expect(zip[1]).toBe(0x4b);
+ });
+
+ it("adds a notice entry when a file source is missing", async () => {
+ const page = createPage({
+ items: [
+ createItem({
+ id: "missing-file",
+ name: "Lecture.pdf",
+ sortOrder: 1000,
+ type: "file",
+ }),
+ ],
+ revision: 1,
+ });
+ const entries = await buildWorkspaceExportEntries(
+ createEnv(),
+ {
+ getFileSource: async () => {
+ throw new Error("Workspace file source object is missing.");
+ },
+ } as never,
+ page,
+ );
+ const notice = entries.find((entry) => entry.path.endsWith(".missing.txt"));
+
+ expect(notice).toMatchObject({ path: "My Workspace/Lecture.pdf.missing.txt" });
+ expect(new TextDecoder().decode(notice?.data as Uint8Array)).toContain(
+ 'The file "Lecture.pdf" could not be included in this export.',
+ );
+ });
+
+ it("removes generated CSS when extracting fallback PDF text", () => {
+ const text = extractTextFromHtml(`
+
+
+New document 1
+
+
+
+test
+
+`);
+
+ expect(text).toBe("test");
+ });
+});
+
+function createPage(input: { items: WorkspaceItemSummary[]; revision: number }): WorkspacePage {
+ return {
+ itemFacts: [],
+ items: input.items,
+ revision: input.revision,
+ workspace: createWorkspace(),
+ };
+}
+
+function createWorkspace(): WorkspaceSummary {
+ return {
+ archivedAt: null,
+ color: null,
+ createdAt: "2026-07-28T00:00:00.000Z",
+ description: null,
+ icon: null,
+ id: "workspace",
+ lastOpenedAt: null,
+ membershipRole: "owner",
+ name: "My Workspace",
+ updatedAt: "2026-07-28T00:00:00.000Z",
+ };
+}
+
+function createEnv() {
+ return {
+ WORKSPACE_KERNEL_FILES: {
+ get: async () => null,
+ },
+ } as never;
+}
+
+function createItem(input: {
+ id: string;
+ name: string;
+ parentId?: string | null;
+ sortOrder: number;
+ type: WorkspaceItemSummary["type"];
+}): WorkspaceItemSummary {
+ return {
+ color: null,
+ createdAt: "2026-07-28T00:00:00.000Z",
+ deletedAt: null,
+ id: input.id,
+ meta: input.type,
+ metadataJson: {},
+ name: input.name,
+ parentId: input.parentId ?? null,
+ sortOrder: input.sortOrder,
+ title: input.name,
+ type: input.type,
+ updatedAt: "2026-07-28T00:00:00.000Z",
+ workspaceId: "workspace",
+ };
+}
diff --git a/src/features/workspaces/export/workspace-export.ts b/src/features/workspaces/export/workspace-export.ts
new file mode 100644
index 00000000..7d23f669
--- /dev/null
+++ b/src/features/workspaces/export/workspace-export.ts
@@ -0,0 +1,477 @@
+import type { WorkspaceItemSummary, WorkspacePage } from "#/features/workspaces/contracts";
+import { serializeTiptapDocumentToMarkdown } from "#/features/workspaces/documents/document-markdown";
+import { parseTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document";
+import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access";
+import { applyWorkspaceEventToPage } from "#/features/workspaces/model/workspace-page";
+import type { WorkspaceRealtimeEvent } from "#/features/workspaces/realtime/messages";
+import { createStreamingMultipartFile } from "#/lib/http/streaming-multipart";
+import { requireSizedResponseBody } from "#/lib/http/sized-response-body";
+import { createZipArchive, type ZipEntryInput } from "./workspace-zip";
+
+export interface WorkspaceExportInput {
+ env: Cloudflare.Env;
+ kernel: WorkspaceKernelClient;
+ page: WorkspacePage;
+ userId: string;
+}
+
+const gotenbergChromiumHtmlPath = "/forms/chromium/convert/html";
+const gotenbergChromiumHtmlFileName = "index.html";
+const pdfConverterPoolSize = 2;
+const textEncoder = new TextEncoder();
+
+interface WorkspacePdfConverter {
+ fetch(request: Request): Promise;
+ startAndWaitForPorts(options: {
+ cancellationOptions: { portReadyTimeoutMS: number };
+ }): unknown;
+}
+
+export async function exportWorkspaceToZip(input: WorkspaceExportInput) {
+ const page = reconstructWorkspaceState({
+ eventsAfterSnapshot: [],
+ snapshot: input.page,
+ });
+ const entries = await buildWorkspaceExportEntries(input.env, input.kernel, page);
+ const archive = createZipArchive(entries);
+ const fileName = `${sanitizeExportName(page.workspace.name)}.zip`;
+
+ return {
+ body: archive,
+ fileName,
+ };
+}
+
+export function reconstructWorkspaceState(input: {
+ eventsAfterSnapshot: WorkspaceRealtimeEvent[];
+ snapshot: WorkspacePage;
+}): WorkspacePage {
+ return [...input.eventsAfterSnapshot]
+ .sort((left, right) => left.revision - right.revision)
+ .reduce(applyWorkspaceEventToPage, input.snapshot);
+}
+
+export async function buildWorkspaceExportEntries(
+ env: Cloudflare.Env,
+ kernel: WorkspaceKernelClient,
+ page: WorkspacePage,
+): Promise {
+ const workspaceFolderName = sanitizeExportName(page.workspace.name);
+ const pathIndex = buildWorkspaceExportPathIndex(workspaceFolderName, page.items);
+ const entries: ZipEntryInput[] = [{ path: `${workspaceFolderName}/` }];
+ const usedZipPaths = new Set([`${workspaceFolderName}/`.toLocaleLowerCase()]);
+
+ for (const item of page.items) {
+ const zipPath = pathIndex.get(item.id);
+ if (!zipPath) {
+ continue;
+ }
+
+ if (item.type === "folder") {
+ entries.push({
+ modifiedAt: new Date(item.updatedAt),
+ path: reserveUniqueZipPath(`${zipPath}/`, usedZipPaths),
+ });
+ continue;
+ }
+
+ if (item.type === "document") {
+ entries.push({
+ data: await renderWorkspaceDocumentPdf(env, kernel, item),
+ modifiedAt: new Date(item.updatedAt),
+ path: reserveUniqueZipPath(`${stripExportExtension(zipPath)}.pdf`, usedZipPaths),
+ });
+ continue;
+ }
+
+ if (item.type === "file") {
+ try {
+ const source = await kernel.getFileSource({ itemId: item.id });
+ const object = await env.WORKSPACE_KERNEL_FILES.get(source.objectKey);
+
+ if (!object) {
+ throw new Error("Workspace file object was not found.");
+ }
+
+ entries.push({
+ data: await object.arrayBuffer(),
+ modifiedAt: new Date(item.updatedAt),
+ path: reserveUniqueZipPath(
+ replacePathBaseName(zipPath, sanitizeExportName(source.fileName)),
+ usedZipPaths,
+ ),
+ });
+ } catch (error) {
+ void recordWorkspaceExportFailure({
+ error,
+ event: "workspace_export_file",
+ fields: {
+ item_id: item.id,
+ workspace_id: item.workspaceId,
+ },
+ });
+ entries.push({
+ data: createMissingWorkspaceFileNotice(item.name),
+ modifiedAt: new Date(item.updatedAt),
+ path: reserveUniqueZipPath(`${zipPath}.missing.txt`, usedZipPaths),
+ });
+ }
+ }
+ }
+
+ return entries;
+}
+
+function createMissingWorkspaceFileNotice(fileName: string) {
+ return textEncoder.encode(
+ [
+ `The file "${fileName}" could not be included in this export.`,
+ "",
+ "Its stored file data is missing from the workspace file storage.",
+ ].join("\n"),
+ );
+}
+
+export function buildWorkspaceExportPathIndex(
+ workspaceFolderName: string,
+ items: WorkspaceItemSummary[],
+) {
+ const childrenByParentId = new Map();
+ const pathsByItemId = new Map();
+
+ for (const item of items) {
+ const children = childrenByParentId.get(item.parentId) ?? [];
+ children.push(item);
+ childrenByParentId.set(item.parentId, children);
+ }
+
+ const visit = (parentId: string | null, parentPath: string) => {
+ const usedNames = new Set();
+ const children = [...(childrenByParentId.get(parentId) ?? [])].sort(compareExportItems);
+
+ for (const item of children) {
+ const name = createUniqueExportName(sanitizeExportName(item.name), usedNames);
+ const path = `${parentPath}/${name}`;
+ pathsByItemId.set(item.id, path);
+
+ if (item.type === "folder") {
+ visit(item.id, path);
+ }
+ }
+ };
+
+ visit(null, workspaceFolderName);
+ return pathsByItemId;
+}
+
+export function sanitizeExportName(name: string | null | undefined) {
+ const sanitized = (name ?? "")
+ .replace(/[<>:"/\\|?*]/g, "_")
+ .split("")
+ .map((character) => (character.charCodeAt(0) < 32 ? "_" : character))
+ .join("")
+ .replace(/^\.+/g, "")
+ .replace(/\.+$/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return sanitized || "Untitled";
+}
+
+export function createUniqueExportName(name: string, usedNames: Set) {
+ let candidate = name;
+ let index = 1;
+
+ while (usedNames.has(candidate.toLocaleLowerCase())) {
+ candidate = `${name} (${index})`;
+ index += 1;
+ }
+
+ usedNames.add(candidate.toLocaleLowerCase());
+ return candidate;
+}
+
+async function renderWorkspaceDocumentPdf(
+ env: Cloudflare.Env,
+ kernel: WorkspaceKernelClient,
+ item: WorkspaceItemSummary,
+) {
+ try {
+ const checkpoint = await kernel.readDocumentCheckpoint({ itemId: item.id });
+ const document = parseTiptapDocumentJson(checkpoint.content);
+ const markdown = serializeTiptapDocumentToMarkdown(document);
+ const html = await createDocumentExportHtml(item.name, markdown);
+
+ return await renderHtmlToPdf(env, {
+ fileName: gotenbergChromiumHtmlFileName,
+ html,
+ title: item.name,
+ });
+ } catch (error) {
+ void recordWorkspaceExportFailure({
+ error,
+ event: "workspace_export_document",
+ fields: {
+ item_id: item.id,
+ workspace_id: item.workspaceId,
+ },
+ });
+ throw new Error("Unable to render a workspace document for export.");
+ }
+}
+
+async function recordWorkspaceExportFailure(input: {
+ error: unknown;
+ event: string;
+ fields: Record;
+}) {
+ const { recordOperationalFailure } =
+ await import("#/integrations/observability/operational-events");
+
+ recordOperationalFailure(input);
+}
+
+async function renderHtmlToPdf(
+ env: Cloudflare.Env,
+ input: {
+ fileName: string;
+ html: string;
+ title: string;
+ },
+): Promise {
+ const converter = await getWorkspacePdfConverter(env);
+
+ if (!converter) {
+ return createFallbackTextPdf(extractTextFromHtml(input.html));
+ }
+
+ const convertedPdf = await renderHtmlWithWorkspacePdfConverter(converter, input).catch((error) => {
+ void recordWorkspaceExportFailure({
+ error,
+ event: "workspace_export_pdf_converter",
+ fields: {
+ renderer: "office_pdf_converter",
+ },
+ });
+ return null;
+ });
+
+ return convertedPdf ?? createFallbackTextPdf(extractTextFromHtml(input.html));
+}
+
+async function getWorkspacePdfConverter(env: Cloudflare.Env) {
+ try {
+ const { getRandom } = await import("@cloudflare/containers");
+ return (await getRandom(
+ env.OFFICE_PDF_CONVERTER,
+ pdfConverterPoolSize,
+ )) as WorkspacePdfConverter | null;
+ } catch {
+ return null;
+ }
+}
+
+async function renderHtmlWithWorkspacePdfConverter(
+ converter: WorkspacePdfConverter,
+ input: {
+ fileName: string;
+ html: string;
+ },
+): Promise {
+ const htmlBytes = textEncoder.encode(input.html);
+ const multipart = createStreamingMultipartFile({
+ body: new Blob([htmlBytes]).stream(),
+ contentType: "text/html; charset=utf-8",
+ fileName: input.fileName,
+ formFieldName: "files",
+ sizeBytes: htmlBytes.byteLength,
+ });
+
+ await Promise.resolve(
+ converter.startAndWaitForPorts({
+ cancellationOptions: {
+ portReadyTimeoutMS: 60_000,
+ },
+ }),
+ );
+
+ const [response] = await Promise.all([
+ converter.fetch(
+ new Request(`http://office-pdf-converter${gotenbergChromiumHtmlPath}`, {
+ body: multipart.body,
+ duplex: "half",
+ headers: { "content-type": multipart.contentType },
+ method: "POST",
+ } as RequestInit & { duplex: "half" }),
+ ),
+ multipart.done,
+ ]);
+
+ if (!response.ok) {
+ throw new Error(`HTML to PDF conversion failed with status ${response.status}.`);
+ }
+
+ const sizedBody = requireSizedResponseBody(
+ response,
+ () => new Error("HTML to PDF conversion returned an empty PDF."),
+ );
+
+ return new Uint8Array(await new Response(sizedBody.body).arrayBuffer());
+}
+
+async function createDocumentExportHtml(title: string, markdown: string) {
+ const body = await renderMarkdownToHtml(markdown);
+
+ return `
+
+
+
+${escapeHtml(title)}
+
+
+
+
+${body}
+
+
+`;
+}
+
+async function renderMarkdownToHtml(markdown: string) {
+ const [{ unified }, remarkParse, remarkGfm, remarkRehype, rehypeStringify] = await Promise.all([
+ import("unified"),
+ import("remark-parse"),
+ import("remark-gfm"),
+ import("remark-rehype"),
+ import("rehype-stringify"),
+ ]);
+ const file = await unified()
+ .use(remarkParse.default)
+ .use(remarkGfm.default)
+ .use(remarkRehype.default)
+ .use(rehypeStringify.default)
+ .process(markdown);
+
+ return String(file);
+}
+
+function createFallbackTextPdf(text: string) {
+ const lines = text.split(/\r?\n/).flatMap((line) => wrapPdfLine(line, 88));
+ const content = `BT
+/F1 11 Tf
+50 742 Td
+14 TL
+${lines.map((line) => `(${escapePdfText(line)}) Tj T*`).join("\n")}
+ET`;
+ const objects = [
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+ `<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
+ ];
+ let pdf = "%PDF-1.4\n";
+ const offsets = [0];
+
+ for (const [index, object] of objects.entries()) {
+ offsets.push(pdf.length);
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
+ }
+
+ const xrefOffset = pdf.length;
+ pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
+ for (const offset of offsets.slice(1)) {
+ pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
+ }
+ pdf += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
+
+ return textEncoder.encode(pdf);
+}
+
+function replacePathBaseName(path: string, baseName: string) {
+ const slashIndex = path.lastIndexOf("/");
+ return slashIndex === -1 ? baseName : `${path.slice(0, slashIndex + 1)}${baseName}`;
+}
+
+function reserveUniqueZipPath(path: string, usedZipPaths: Set) {
+ const isDirectory = path.endsWith("/");
+ const trimmedPath = isDirectory ? path.slice(0, -1) : path;
+ const slashIndex = trimmedPath.lastIndexOf("/");
+ const parentPath = slashIndex === -1 ? "" : trimmedPath.slice(0, slashIndex + 1);
+ const baseName = slashIndex === -1 ? trimmedPath : trimmedPath.slice(slashIndex + 1);
+ const extensionIndex = isDirectory ? -1 : baseName.lastIndexOf(".");
+ const stem = extensionIndex > 0 ? baseName.slice(0, extensionIndex) : baseName;
+ const extension = extensionIndex > 0 ? baseName.slice(extensionIndex) : "";
+ let candidate = path;
+ let index = 1;
+
+ while (usedZipPaths.has(candidate.toLocaleLowerCase())) {
+ candidate = `${parentPath}${stem} (${index})${extension}${isDirectory ? "/" : ""}`;
+ index += 1;
+ }
+
+ usedZipPaths.add(candidate.toLocaleLowerCase());
+ return candidate;
+}
+
+function stripExportExtension(path: string) {
+ const slashIndex = path.lastIndexOf("/");
+ const dotIndex = path.lastIndexOf(".");
+
+ return dotIndex > slashIndex + 1 ? path.slice(0, dotIndex) : path;
+}
+
+function compareExportItems(left: WorkspaceItemSummary, right: WorkspaceItemSummary) {
+ return left.sortOrder - right.sortOrder || left.name.localeCompare(right.name);
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+export function extractTextFromHtml(html: string) {
+ return html
+ .replace(/]*>[\s\S]*?<\/head>/gi, "\n")
+ .replace(/