From 97469dbabf7181570bbb645e93eff5f0fe66e07b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:15:44 -0700 Subject: [PATCH] feat(site-builder): "Download site (.zip)" command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- craft/src/editor/CanvasOverlays.tsx | 2 + craft/src/hooks/useSiteExport.test.ts | 150 ++++++++++++++++++++++ craft/src/hooks/useSiteExport.ts | 96 ++++++++++++++ craft/src/panels/topbar/ExportCommand.tsx | 29 +++++ 4 files changed, 277 insertions(+) create mode 100644 craft/src/hooks/useSiteExport.test.ts create mode 100644 craft/src/hooks/useSiteExport.ts create mode 100644 craft/src/panels/topbar/ExportCommand.tsx 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; +};