feat(site-builder): "Download site (.zip)" command

Registers site.download-zip (WHP mode only; shows in the ⋯ menu and the
palette) via ExportCommand, mounted in CanvasOverlays. useSiteExport POSTs
?action=export_zip with the CSRF header: a zip answer is saved through a
Blob + anchor click, anything else (JSON error, older backend, network
failure, a stream cut short) becomes an error notice.

Refs #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:15:44 -07:00
1 parent d39e1ecc31
commit 97469dbabf
4 files changed
+277

No files matched your search

+2
View File
@@ -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 = () => (
<CommandPalette />
<HistoryHost />
<SpacingHandles />
<ExportCommand />
</>
);
+150
View File
@@ -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<typeof vi.fn>;
let createUrl: ReturnType<typeof vi.fn>;
let revokeUrl: ReturnType<typeof vi.fn>;
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();
});
});
+96
View File
@@ -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<boolean> {
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<boolean> => {
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 };
}
+29
View File
@@ -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;
};