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:
1 parent
d39e1ecc31
commit
97469dbabf
4 files changed
+277
No files matched your search
@@ -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 />
|
||||
</>
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user