diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx
index 810feeb..d525da8 100644
--- a/craft/src/editor/CanvasOverlays.tsx
+++ b/craft/src/editor/CanvasOverlays.tsx
@@ -6,6 +6,7 @@ import { RichTextToolbar } from './RichTextToolbar';
import { CommandPalette } from '../panels/command-palette/CommandPalette';
import { HistoryHost } from '../panels/history/HistoryModal';
import { SpacingHandles } from './SpacingHandles';
+import { ExportCommand } from '../panels/topbar/ExportCommand';
/**
* Single mount point for editor-wide overlays rendered next to the shell.
@@ -21,5 +22,6 @@ export const CanvasOverlays: React.FC = () => (
+
>
);
diff --git a/craft/src/hooks/useSiteExport.test.ts b/craft/src/hooks/useSiteExport.test.ts
new file mode 100644
index 0000000..03a4d68
--- /dev/null
+++ b/craft/src/hooks/useSiteExport.test.ts
@@ -0,0 +1,150 @@
+import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { act } from 'react-dom/test-utils';
+import { downloadSiteZip, zipFilenameFrom, exportErrorMessage } from './useSiteExport';
+import { ExportCommand } from '../panels/topbar/ExportCommand';
+import { EditorConfigProvider } from '../state/EditorConfigContext';
+import { getCommand, __resetCommandsForTests } from '../editor/commands';
+import type { WhpConfig } from '../types';
+
+const cfg: WhpConfig = {
+ user: 'alice', apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 7,
+ siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
+};
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+}
+function zipResponse(bytes = 'PK\x05\x06' + '\0'.repeat(18), disposition = 'attachment; filename="example.com-20261007.zip"'): Response {
+ return new Response(bytes, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Disposition': disposition } });
+}
+
+let fetchMock: ReturnType;
+let createUrl: ReturnType;
+let revokeUrl: ReturnType;
+let clicks: HTMLAnchorElement[];
+const origCreate = URL.createObjectURL;
+const origRevoke = URL.revokeObjectURL;
+
+beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ createUrl = vi.fn(() => 'blob:fake');
+ revokeUrl = vi.fn();
+ (URL as any).createObjectURL = createUrl;
+ (URL as any).revokeObjectURL = revokeUrl;
+ clicks = [];
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { clicks.push(this); });
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ (URL as any).createObjectURL = origCreate;
+ (URL as any).revokeObjectURL = origRevoke;
+});
+
+describe('downloadSiteZip', () => {
+ test('zip answer: POSTs with the CSRF header, saves the Blob via an anchor, success notice', async () => {
+ fetchMock.mockResolvedValue(zipResponse());
+ const notify = vi.fn(() => 'id');
+ await expect(downloadSiteZip(cfg, notify)).resolves.toBe(true);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('/api/site-builder.php?action=export_zip&site_id=7');
+ expect(init).toMatchObject({ method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': 'tok' } });
+
+ expect(createUrl).toHaveBeenCalledTimes(1);
+ expect(createUrl.mock.calls[0][0]).toMatchObject({ size: 22, type: 'application/zip' });
+ expect(clicks).toHaveLength(1);
+ expect(clicks[0].download).toBe('example.com-20261007.zip');
+ expect(clicks[0].getAttribute('href')).toBe('blob:fake');
+ expect(document.body.contains(clicks[0])).toBe(false); // cleaned up
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'site-export', kind: 'success' }));
+ });
+
+ test('JSON error answer: the server message as an error notice, no download', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(409, { success: false, code: 'not_published', error: 'This site has not been published yet.' }));
+ const notify = vi.fn(() => 'id');
+ await expect(downloadSiteZip(cfg, notify)).resolves.toBe(false);
+ expect(notify).toHaveBeenLastCalledWith({ id: 'site-export', kind: 'error', message: 'This site has not been published yet.' });
+ expect(createUrl).not.toHaveBeenCalled();
+ expect(clicks).toHaveLength(0);
+ });
+
+ test('a 200 that is JSON (not a zip) is still an error, never saved', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(200, { success: false, error: 'odd' }));
+ const notify = vi.fn(() => 'id');
+ await expect(downloadSiteZip(cfg, notify)).resolves.toBe(false);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'error', message: 'odd' }));
+ expect(clicks).toHaveLength(0);
+ });
+
+ test('older backend (Invalid action) -> "not available" message', async () => {
+ fetchMock.mockResolvedValue(jsonResponse(400, { success: false, error: 'Invalid action. Supported: get_sites' }));
+ const notify = vi.fn(() => 'id');
+ await downloadSiteZip(cfg, notify);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'error', message: expect.stringMatching(/not available/) }));
+ });
+
+ test('network failure and a stream cut short are both reported', async () => {
+ const notify = vi.fn(() => 'id');
+ fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
+ await expect(downloadSiteZip(cfg, notify)).resolves.toBe(false);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'error', message: expect.stringMatching(/could not be reached/) }));
+
+ const broken = zipResponse();
+ vi.spyOn(broken, 'blob').mockRejectedValue(new TypeError('network error'));
+ fetchMock.mockResolvedValueOnce(broken);
+ await expect(downloadSiteZip(cfg, notify)).resolves.toBe(false);
+ expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'error', message: expect.stringMatching(/part-way/) }));
+ expect(clicks).toHaveLength(0);
+ });
+});
+
+describe('helpers', () => {
+ test('zipFilenameFrom: header name, sanitised; fallback gets .zip', () => {
+ expect(zipFilenameFrom('attachment; filename="a b/../c.zip"', 'x')).toBe('a_b_.._c.zip');
+ expect(zipFilenameFrom(null, 'example.com')).toBe('example.com.zip');
+ });
+ test('exportErrorMessage: falls back to the HTTP status', () => {
+ expect(exportErrorMessage(500, null)).toBe('Download failed (HTTP 500).');
+ });
+});
+
+describe('ExportCommand', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+ const mount = (config: WhpConfig | null) => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ act(() => {
+ root = createRoot(container);
+ root.render(React.createElement(EditorConfigProvider, { config, children: React.createElement(ExportCommand) }));
+ });
+ };
+ beforeEach(() => __resetCommandsForTests());
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ __resetCommandsForTests();
+ });
+
+ test('WHP mode registers site.download-zip in the site group; running it downloads', async () => {
+ mount(cfg);
+ const cmd = getCommand('site.download-zip');
+ expect(cmd).toMatchObject({ title: 'Download site (.zip)', group: 'site' });
+ expect(container.innerHTML).toBe('');
+ fetchMock.mockResolvedValue(zipResponse());
+ await act(async () => { await cmd!.run({} as any); });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(clicks).toHaveLength(1);
+ });
+
+ test('standalone mode registers nothing and makes no request', () => {
+ mount(null);
+ expect(getCommand('site.download-zip')).toBeUndefined();
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/craft/src/hooks/useSiteExport.ts b/craft/src/hooks/useSiteExport.ts
new file mode 100644
index 0000000..e12befc
--- /dev/null
+++ b/craft/src/hooks/useSiteExport.ts
@@ -0,0 +1,96 @@
+import { useCallback, useRef } from 'react';
+import { useEditorConfig } from '../state/EditorConfigContext';
+import { useNotices, type Notice } from '../state/NoticeContext';
+import type { WhpConfig } from '../types';
+
+const NOTICE_ID = 'site-export';
+
+/** `filename="x.zip"` from a Content-Disposition header, reduced to a safe name. */
+export function zipFilenameFrom(disposition: string | null, fallback: string): string {
+ const m = disposition ? /filename="([^"]+)"/i.exec(disposition) : null;
+ const name = (m ? m[1] : fallback).replace(/[^A-Za-z0-9._-]/g, '_');
+ return name.toLowerCase().endsWith('.zip') ? name : `${name}.zip`;
+}
+
+/** The user-facing message for a JSON error answer from `export_zip`. */
+export function exportErrorMessage(status: number, data: any): string {
+ const err = data && typeof data.error === 'string' ? data.error : '';
+ // An older backend without the action answers 400 "Invalid action ...".
+ if (status === 400 && /^Invalid action/i.test(err)) {
+ return 'Downloading the site is not available on this server yet.';
+ }
+ return err || `Download failed (HTTP ${status}).`;
+}
+
+/**
+ * Downloads the PUBLISHED site as a zip (`POST ?action=export_zip`). A JSON answer is
+ * an error (shown as a notice); a zip answer is saved through a Blob + anchor click.
+ * A stream the server cut short makes `blob()` reject, which is reported too.
+ */
+export async function downloadSiteZip(
+ whpConfig: WhpConfig,
+ notify: (n: Notice) => string,
+): Promise {
+ notify({ id: NOTICE_ID, kind: 'info', message: 'Preparing your site download…', timeoutMs: 0 });
+ const fail = (message: string) => {
+ notify({ id: NOTICE_ID, kind: 'error', message });
+ return false;
+ };
+ let resp: Response;
+ try {
+ resp = await fetch(`${whpConfig.apiUrl}?action=export_zip&site_id=${whpConfig.siteId}`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'X-CSRF-Token': whpConfig.csrfToken },
+ });
+ } catch {
+ return fail('Download failed: the server could not be reached.');
+ }
+ const type = (resp.headers.get('content-type') || '').toLowerCase();
+ if (!resp.ok || !type.includes('application/zip')) {
+ let data: any = null;
+ try { data = await resp.json(); } catch { /* not JSON */ }
+ return fail(exportErrorMessage(resp.status, data));
+ }
+ let blob: Blob;
+ try {
+ blob = await resp.blob();
+ } catch {
+ return fail('Download failed part-way. Please try again.');
+ }
+ const url = URL.createObjectURL(blob);
+ try {
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = zipFilenameFrom(resp.headers.get('content-disposition'), whpConfig.siteDomain || 'site');
+ a.rel = 'noopener';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ } finally {
+ // Give the browser a moment to start the save before the URL goes away.
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
+ }
+ notify({ id: NOTICE_ID, kind: 'success', message: 'Site downloaded (the published version).' });
+ return true;
+}
+
+/** `downloadZip()` for the current WHP site; a no-op returning false in standalone mode. */
+export function useSiteExport() {
+ const { isWHP, whpConfig } = useEditorConfig();
+ const { notify } = useNotices();
+ const busy = useRef(false);
+
+ const downloadZip = useCallback(async (): Promise => {
+ if (!isWHP || !whpConfig || busy.current) return false;
+ busy.current = true;
+ try {
+ return await downloadSiteZip(whpConfig, notify);
+ } finally {
+ busy.current = false;
+ }
+ }, [isWHP, whpConfig, notify]);
+
+ return { downloadZip };
+}
diff --git a/craft/src/panels/topbar/ExportCommand.tsx b/craft/src/panels/topbar/ExportCommand.tsx
new file mode 100644
index 0000000..7716d69
--- /dev/null
+++ b/craft/src/panels/topbar/ExportCommand.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { useRegisterCommands } from '../../editor/commands';
+import { useEditorConfig } from '../../state/EditorConfigContext';
+import { useSiteExport } from '../../hooks/useSiteExport';
+
+/**
+ * Registers `site.download-zip` ("Download site (.zip)") in WHP mode -- it then shows
+ * in the top-bar ⋯ menu and the Cmd+K palette. Renders nothing; mounted once in
+ * CanvasOverlays. Standalone mode registers nothing.
+ */
+export const ExportCommand: React.FC = () => {
+ const { isWHP } = useEditorConfig();
+ const { downloadZip } = useSiteExport();
+
+ useRegisterCommands(
+ isWHP
+ ? [{
+ id: 'site.download-zip',
+ title: 'Download site (.zip)',
+ group: 'site',
+ keywords: ['export', 'zip', 'backup', 'download', 'archive'],
+ when: () => isWHP,
+ run: async () => { await downloadZip(); },
+ }]
+ : [],
+ [isWHP, downloadZip],
+ );
+ return null;
+};