Merge fe-after-backend: client_id, warnings, limits, head parity

This commit is contained in:
shadowdao committed 2026-10-07 13:52:24 -07:00
commit bdb4789903
17 files changed
+581 -38

No files matched your search

@@ -60,3 +60,40 @@ describe('ImageBlock render falls back to the placeholder for an explicit empty
container.remove();
});
});
describe('ImageBlock drop upload honours the server limits and error text', () => {
const dropFile = (file: File) => {
const img = container.querySelector('img')!;
const ev = new Event('drop', { bubbles: true, cancelable: true }) as any;
ev.dataTransfer = { files: [file] };
return act(async () => { img.dispatchEvent(ev); await Promise.resolve(); await Promise.resolve(); });
};
test('an oversize image is refused with the readable message and never sent', async () => {
const { setSiteLimits, onUploadError } = await import('../../utils/site-limits');
const errors: string[] = [];
const off = onUploadError((m) => errors.push(m));
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' };
setSiteLimits({ upload_max_bytes: 4 });
render(<ImageBlock />);
await dropFile(new File(['12345'], 'huge.png', { type: 'image/png' }));
expect(fetchMock).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('huge.png');
off(); setSiteLimits(undefined); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals();
});
test('a 413 from the server surfaces its own error text', async () => {
const { onUploadError } = await import('../../utils/site-limits');
const errors: string[] = [];
const off = onUploadError((m) => errors.push(m));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: 'That file is too large. The site builder accepts files up to 64 MB.' }) }));
(window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' };
render(<ImageBlock />);
await dropFile(new File(['x'], 'a.png', { type: 'image/png' }));
expect(errors).toEqual(['That file is too large. The site builder accepts files up to 64 MB.']);
off(); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals();
});
});
+4 -18
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties, useCallback, useRef } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeImageUrl } from '../../utils/escape';
import { uploadAsset } from '../../utils/assets';
export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
@@ -28,24 +29,9 @@ export function pxAttr(v: unknown): string | undefined {
return m ? m[1] : undefined;
}
// Helper: upload a file to the WHP API and return the proxy URL
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
// Upload goes through the shared util: it enforces limits.upload_max_bytes and
// reports the server's own error text (e.g. the 413 size message).
const uploadToWhp = uploadAsset;
export const ImageBlock: UserComponent<ImageBlockProps> = ({
src = PLACEHOLDER_SRC,
+15
View File
@@ -75,3 +75,18 @@ describe('NoticeHost', () => {
act(() => r.unmount());
});
});
describe('NoticeHost shows upload refusals', () => {
test('an upload error reported by the upload helpers becomes an error notice with the server text', async () => {
const { reportUploadError } = await import('../utils/site-limits');
const el = document.createElement('div');
document.body.appendChild(el);
let r: ReturnType<typeof createRoot>;
act(() => { r = createRoot(el); r.render(<NoticeProvider><NoticeHost /></NoticeProvider>); });
act(() => { reportUploadError('That file is too large. The site builder accepts files up to 64 MB.'); });
const alert = el.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('That file is too large. The site builder accepts files up to 64 MB.');
act(() => { r.unmount(); });
el.remove();
});
});
+5 -2
View File
@@ -1,9 +1,12 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useNotices } from '../state/NoticeContext';
import { onUploadError } from '../utils/site-limits';
/** Renders the active notice stack (bottom-left; above the bars on mobile). */
export const NoticeHost: React.FC = () => {
const { notices, dismiss } = useNotices();
const { notices, dismiss, notify } = useNotices();
// Upload helpers have no React context: they report refusals on a small bus.
useEffect(() => onUploadError((message) => { notify({ id: 'upload-error', kind: 'error', message }); }), [notify]);
if (notices.length === 0) return null;
return (
<div className="notice-host">
+44
View File
@@ -0,0 +1,44 @@
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 { useAssets } from './useAssets';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { setSiteLimits } from '../utils/site-limits';
import type { WhpConfig } from '../types';
const cfg: WhpConfig = { user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'e.com', siteName: 'E', backUrl: '/', isRoot: false };
let container: HTMLDivElement; let root: Root; let api: ReturnType<typeof useAssets>; let fetchMock: ReturnType<typeof vi.fn>;
const Probe: React.FC = () => { api = useAssets(); return null; };
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
container = document.createElement('div'); document.body.appendChild(container);
act(() => { root = createRoot(container); root.render(<EditorConfigProvider config={cfg}><Probe /></EditorConfigProvider>); });
});
afterEach(() => { act(() => root.unmount()); container.remove(); setSiteLimits(undefined); vi.unstubAllGlobals(); });
describe('useAssets.uploadAsset', () => {
test('refuses a file over limits.upload_max_bytes without sending it', async () => {
setSiteLimits({ upload_max_bytes: 5 });
let r: unknown = 'pending';
await act(async () => { r = await api.uploadAsset(new File(['123456'], 'big.png')); });
expect(r).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(api.error).toMatch(/big\.png.*too large/);
});
test('a 413 shows the server error text, not "Upload failed: 413"', async () => {
const text = 'That file is too large. The site builder accepts files up to 64 MB.';
fetchMock.mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: text }) });
await act(async () => { await api.uploadAsset(new File(['x'], 'a.png')); });
expect(api.error).toBe(text);
});
test('a non-JSON failure still says which status', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 502, json: async () => { throw new Error('html'); } });
await act(async () => { await api.uploadAsset(new File(['x'], 'a.png')); });
expect(api.error).toBe('Upload failed: 502');
});
});
+7 -3
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useEditorConfig } from '../state/EditorConfigContext';
import { AssetData } from '../types';
import { uploadSizeError, uploadFailureText } from '../utils/site-limits';
export function useAssets() {
const { whpConfig, isWHP } = useEditorConfig();
@@ -38,6 +39,8 @@ export function useAssets() {
const uploadAsset = useCallback(async (file: File): Promise<string | null> => {
if (!isWHP || !whpConfig) return null;
const tooBig = uploadSizeError(file);
if (tooBig) { setError(tooBig); return null; }
setLoading(true);
setError(null);
try {
@@ -52,9 +55,10 @@ export function useAssets() {
body: formData,
}
);
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Upload failed');
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
// The server's `error` is human text (the 413 size message included): show it as-is.
if (!resp.ok || !data?.success) throw new Error(uploadFailureText(data, resp.status));
const newAsset: AssetData = {
name: data.name || file.name,
+13
View File
@@ -76,6 +76,19 @@ describe('useStockPhotos.search', () => {
expect(api.availability).toBe('unknown');
});
test('the human `error` text is shown as-is; the machine `code` is never shown', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'Stock photos are busy right now. Please try again in a minute.', code: 'rate_limited' }, 429));
await act(async () => { await api.search('fox'); });
expect(api.error).toBe('Stock photos are busy right now. Please try again in a minute.');
expect(api.availability).toBe('unknown');
});
test('stock_disabled in `error` still hides the tab even when a human `code`/message is present', async () => {
fetchMock.mockReturnValue(json({ success: false, error: 'stock_disabled', code: 'stock_disabled' }, 404));
await act(async () => { await api.search('fox'); });
expect(api.availability).toBe('disabled');
});
test('a new query aborts the in-flight one', async () => {
const signals: AbortSignal[] = [];
fetchMock.mockImplementation((_u: string, init: any) => {
@@ -0,0 +1,178 @@
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';
/**
* Frontend half of the backend final-fix wave: client_id on every save,
* already_saved, save warnings via notices, load() limits, project_max_bytes warning,
* and the server's `error` text on a refused save.
*/
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } };
const actions = { history: { undo: vi.fn(), redo: vi.fn(), clear: vi.fn() }, deserialize: vi.fn() };
return { actions, query, ...(collector ? collector({}, query) : {}) };
},
}));
import { LoadGate } from '../editor/LoadGate';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
import { NoticeProvider, useNotices } from '../state/NoticeContext';
import { PageProvider } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { getSiteLimits, setSiteLimits } from '../utils/site-limits';
import { WhpConfig } from '../types';
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
};
const jsonResp = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
let container: HTMLDivElement;
let root: Root;
let fetchMock: ReturnType<typeof vi.fn>;
let api: ReturnType<typeof useWhpApi>;
let guard: ReturnType<typeof useSaveGuard>;
let notices: ReturnType<typeof useNotices>;
const Probe: React.FC = () => { api = useWhpApi(); guard = useSaveGuard(); notices = useNotices(); return null; };
const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); });
const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`));
const sentBody = (n = 0) => JSON.parse(calls('save')[n][1].body as string);
async function mount() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SaveGuardProvider><NoticeProvider><SiteDesignProvider><PageProvider>
<LoadGate />
<Probe />
</PageProvider></SiteDesignProvider></NoticeProvider></SaveGuardProvider>
</EditorConfigProvider>,
);
});
await flush();
}
const unmount = () => { act(() => { root.unmount(); }); container.remove(); };
let loadBody: Record<string, unknown>;
let saveResponses: Array<[unknown, number?]>;
beforeEach(() => {
loadBody = { success: true, project: null, rev: 'r1' };
saveResponses = [];
fetchMock = vi.fn((url: string) => {
const u = String(url);
if (u.includes('action=load')) return Promise.resolve(jsonResp(loadBody));
if (u.includes('action=save')) {
const [body, status] = saveResponses.shift() ?? [{ success: true, rev: 'r2' }];
return Promise.resolve(jsonResp(body, status));
}
return Promise.resolve(jsonResp({ success: true }));
});
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(console, 'error').mockImplementation(() => {});
setSiteLimits(undefined);
});
afterEach(() => {
unmount();
vi.unstubAllGlobals();
vi.restoreAllMocks();
setSiteLimits(undefined);
});
describe('client_id', () => {
test('every save carries the same valid client_id within one editor load', async () => {
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
await act(async () => { await api.save({ forceEmpty: true }); });
const a = sentBody(0).client_id, b = sentBody(1).client_id;
expect(a).toMatch(/^[A-Za-z0-9_-]{8,64}$/);
expect(b).toBe(a);
});
test('a new editor load gets a different client_id (in memory, not shared via storage)', async () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem');
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
const first = sentBody(0).client_id;
unmount();
fetchMock.mockClear();
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
expect(sentBody(0).client_id).not.toBe(first);
// A duplicated tab copies sessionStorage: the id must never be written to any storage.
expect(setItem.mock.calls.filter(([, v]) => String(v).includes(first))).toEqual([]);
expect(JSON.stringify({ ...sessionStorage })).not.toContain(first);
expect(JSON.stringify({ ...localStorage })).not.toContain(first);
});
});
describe('already_saved', () => {
test('is a success and its rev becomes the base of the next save', async () => {
saveResponses = [[{ success: true, draft: true, rev: 'r-recovered', already_saved: true, pages: [] }]];
await mount();
let res: any;
await act(async () => { res = await api.save({ forceEmpty: true }); });
expect(res).toMatchObject({ success: true, already_saved: true });
expect(guard.getRev()).toBe('r-recovered');
await act(async () => { await api.save({ forceEmpty: true }); });
expect(sentBody(1).base_rev).toBe('r-recovered');
});
});
describe('save warnings', () => {
test('warnings[] from a successful save reach the notice system', async () => {
saveResponses = [[{ success: true, rev: 'r2', warnings: ['Page "About" could not be exported; the previous copy was kept.'] }]];
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
expect(notices.notices.map((n) => n.message).join('\n')).toContain('Page "About" could not be exported; the previous copy was kept.');
});
test('no warnings -> no notice', async () => {
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
expect(notices.notices).toEqual([]);
});
});
describe('save refusals show the server error as-is', () => {
test('a 413 (project too large) result carries the server error text as its message', async () => {
const text = 'This site is too large to save (over 20 MB of page data).';
saveResponses = [[{ success: false, error: text }, 413]];
await mount();
let res: any;
await act(async () => { res = await api.save({ forceEmpty: true }); });
expect(res.success).toBe(false);
expect(res.message).toBe(text);
});
});
describe('limits from load', () => {
test('load() adopts limits.upload_max_bytes / project_max_bytes', async () => {
loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 20971520 } };
await mount();
expect(getSiteLimits()).toEqual({ uploadMaxBytes: 67108864, projectMaxBytes: 20971520 });
});
test('a payload near project_max_bytes warns before saving (and still sends: the server decides)', async () => {
loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 100 } };
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
const msgs = notices.notices.map((n) => n.message).join('\n');
expect(msgs).toMatch(/too large|close to/i);
expect(calls('save')).toHaveLength(1);
});
test('no warning when the payload is well under project_max_bytes', async () => {
loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 20971520 } };
await mount();
await act(async () => { await api.save({ forceEmpty: true }); });
expect(notices.notices).toEqual([]);
});
});
+41 -10
View File
@@ -7,6 +7,8 @@ import { exportBodyHtml, type ExportZone } from '../utils/html-export';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types';
import { useSaveGuard } from '../state/SaveGuardContext';
import { useNotices } from '../state/NoticeContext';
import { setSiteLimits, getSiteLimits, formatMegabytes } from '../utils/site-limits';
import { useProjectExtras } from '../state/ProjectExtrasContext';
import { GLOBAL_SECTIONS_KEY, isGlobalZoneId } from '../utils/global-sections';
@@ -254,6 +256,16 @@ export class UnreadableSiteError extends Error {
}
}
/** Warn from this share of project_max_bytes (the server refuses above it). */
const PROJECT_SIZE_WARN_RATIO = 0.9;
function projectSizeWarning(bodyBytes: number): string | null {
const max = getSiteLimits().projectMaxBytes;
if (max === undefined || bodyBytes < max * PROJECT_SIZE_WARN_RATIO) return null;
return `This site's data (${formatMegabytes(bodyBytes)}) is close to or over the ${formatMegabytes(max)} save limit, so saving may be refused. `
+ 'Large images pasted into the page are the usual cause: upload them as images instead.';
}
/** What save()/publish() resolve to: the server's JSON, or a synthesized failure. */
export interface ApiResult {
success: boolean;
@@ -357,8 +369,9 @@ export function useWhpApi() {
const {
loadState, forceEmptyNextSave, beginSave, endSave,
getRev, setRev, setConflict, forceOverwriteNextSave,
saveInFlight, isDirty,
saveInFlight, isDirty, clientId,
} = useSaveGuard();
const { notify, dismiss } = useNotices();
// A save queued behind another runs later: it must build its payload from
// the editor as it is THEN, not from the render that queued it.
@@ -409,6 +422,24 @@ export function useWhpApi() {
extras: zone ? { ...allExtras, [GLOBAL_SECTIONS_KEY]: zone.library } : allExtras,
});
const body = JSON.stringify({
...payload,
// Present on every save so the server can detect a newer copy; left
// out only when load never reported a rev (an older backend).
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
// Names this editor load, so a save whose answer was lost does not
// read as a conflict with itself on the next save.
client_id: clientId,
...(forceEmpty ? { force_empty: true } : {}),
...(forceOverwrite ? { force_overwrite: true } : {}),
});
// Heads-up before the server refuses with 413 (it measures the stored
// project, not this body, so this only warns; the server decides).
const sizeWarning = projectSizeWarning(body.length);
if (sizeWarning) notify({ id: 'project-size', kind: 'info', message: sizeWarning, timeoutMs: 0 });
else dismiss('project-size');
let result: ApiResult;
try {
result = await withTimeout('Save', async (signal) => {
@@ -419,14 +450,7 @@ export function useWhpApi() {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify({
...payload,
// Present on every save so the server can detect a newer copy; left
// out only when load never reported a rev (an older backend).
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
...(forceEmpty ? { force_empty: true } : {}),
...(forceOverwrite ? { force_overwrite: true } : {}),
}),
body,
});
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
@@ -456,14 +480,19 @@ export function useWhpApi() {
if (result.success) {
if (forceEmpty) forceEmptyNextSave.current = false;
if (forceOverwrite) forceOverwriteNextSave.current = false;
// Also covers already_saved: the server answers with the rev it holds.
if (typeof result.rev === 'string') setRev(result.rev);
// Pages kept at their previous copy and the like: saved, but worth knowing.
const warnings = Array.isArray(result.warnings) ? result.warnings.filter((w): w is string => typeof w === 'string' && w !== '') : [];
if (warnings.length) notify({ id: 'save-warnings', kind: 'info', message: warnings.join(' '), timeoutMs: 0 });
else dismiss('save-warnings');
succeeded = true;
}
return result;
} finally {
endSave(ticket, succeeded);
}
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView, pendingCanvasState]);
}, [whpConfig, clientId, notify, dismiss, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView, pendingCanvasState]);
/**
* Save, one at a time. Every save (Save button, autosave, Keep mine, the
@@ -572,6 +601,8 @@ export function useWhpApi() {
// The baseline for every later save's conflict check. proj._rev (inside
// the project) is ignored: buildSavePayload never echoes it back.
if (typeof data.rev === 'string' || data.rev === null) setRev(data.rev);
// Size limits the server enforces, kept so uploads/saves can refuse early.
setSiteLimits(data.limits);
// project: null is a success (brand-new site, nothing saved yet).
if (data.project) {
+10
View File
@@ -168,6 +168,16 @@ describe('TopBar publish-warnings wiring', () => {
expect(container.textContent).toContain('Warning two.');
});
test('the new sitemap / coming-soon publish warnings are shown verbatim', async () => {
const coming = 'Coming-soon mode was turned off: this publish replaced the coming-soon home page, so the site is now live and open to search engines.';
publishMock.mockResolvedValue({ success: true, warnings: [coming] });
render();
await clickPublish();
expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain(coming);
});
test('a clean publish (no warnings key) renders no banner at all', async () => {
publishMock.mockResolvedValue({ success: true });
render();
+17 -1
View File
@@ -52,6 +52,19 @@ interface SaveGuardContextValue {
/** True while the server has no copy of this site (load reported rev null) and no save
* has gone through since. Drives the "Not saved yet" pill. */
neverSaved: boolean;
/** Identifies this editor load to the server (save `client_id`). In memory only: a
* duplicated tab copies sessionStorage, and two tabs must never share an id. */
clientId: string;
}
/** 8-64 chars of [A-Za-z0-9_-], the shape the backend accepts. */
export function newClientId(): string {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (c?.randomUUID) return c.randomUUID();
const bytes = new Uint8Array(16);
if (c?.getRandomValues) c.getRandomValues(bytes);
else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
/** Identifies the editor state a save was built from. */
@@ -86,6 +99,7 @@ const defaultValue: SaveGuardContextValue = {
reloadLatest: () => {},
saveInFlight: { current: null },
neverSaved: false,
clientId: newClientId(),
};
const SaveGuardContext = createContext<SaveGuardContextValue>(defaultValue);
@@ -102,6 +116,7 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable f
const revRef = useRef<string | null | undefined>(undefined);
const [neverSaved, setNeverSaved] = useState(false);
const saveInFlight = useRef<Promise<unknown> | null>(null);
const [clientId] = useState(newClientId);
const getRev = useCallback(() => revRef.current, []);
const setRev = useCallback((rev: string | null) => {
revRef.current = rev;
@@ -191,7 +206,8 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable f
reloadLatest,
saveInFlight,
neverSaved,
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest, neverSaved]);
clientId,
}), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest, neverSaved, clientId]);
return <SaveGuardContext.Provider value={value}>{children}</SaveGuardContext.Provider>;
};
+47
View File
@@ -1,5 +1,6 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { uploadAsset, listAssets, Asset } from './assets';
import { setSiteLimits, onUploadError } from './site-limits';
const CFG = {
apiUrl: 'https://panel.example.com/api/site-builder',
@@ -70,6 +71,52 @@ describe('uploadAsset', () => {
});
});
describe('uploadAsset: limits and server error text', () => {
let errors: string[];
let off: () => void;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
(window as any).WHP_CONFIG = CFG;
errors = [];
off = onUploadError((m) => errors.push(m));
});
afterEach(() => {
off();
setSiteLimits(undefined);
vi.unstubAllGlobals();
delete (window as any).WHP_CONFIG;
});
test('a file over limits.upload_max_bytes is refused client-side: no request, readable error', async () => {
setSiteLimits({ upload_max_bytes: 10 });
const file = new File(['x'.repeat(11)], 'big.png', { type: 'image/png' });
expect(await uploadAsset(file)).toBeNull();
expect(fetch).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('big.png');
});
test('a file at the limit is sent', async () => {
setSiteLimits({ upload_max_bytes: 10 });
(fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, url: '/a.png' }) });
expect(await uploadAsset(new File(['x'.repeat(10)], 'a.png'))).toBe('/a.png');
expect(errors).toEqual([]);
});
test('a 413 shows the server error text, not "Upload failed: 413"', async () => {
const text = 'That file is too large. The site builder accepts files up to 64 MB.';
(fetch as any).mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: text }) });
expect(await uploadAsset(new File(['x'], 'a.png'))).toBeNull();
expect(errors).toEqual([text]);
});
test('a non-JSON failure falls back to the status', async () => {
(fetch as any).mockResolvedValue({ ok: false, status: 502, json: async () => { throw new Error('html'); } });
expect(await uploadAsset(new File(['x'], 'a.png'))).toBeNull();
expect(errors).toEqual(['Upload failed: 502']);
});
});
describe('listAssets', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
+15 -3
View File
@@ -7,6 +7,8 @@
`Navbar.tsx` / `HeroStylePanel.tsx` / `ImageBlock.tsx` / `VideoBlock.tsx`,
so callers see identical behavior once switched over to this module. */
import { uploadSizeError, uploadFailureText, reportUploadError } from './site-limits';
export interface Asset {
name: string;
url: string;
@@ -18,6 +20,10 @@ export interface Asset {
export async function uploadAsset(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
// Over the server's advertised limit: say so now instead of sending a
// multi-MB body just to be refused.
const tooBig = uploadSizeError(file);
if (tooBig) { reportUploadError(tooBig); return null; }
const formData = new FormData();
formData.append('file', file);
try {
@@ -26,10 +32,16 @@ export async function uploadAsset(file: File): Promise<string | null> {
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
let data: any = null;
try { data = await resp.json(); } catch { /* non-JSON body */ }
if (data?.success && data.url) return data.url;
// The server's `error` is human text (e.g. the 413 size message): show it as-is.
reportUploadError(uploadFailureText(data, resp.status));
return null;
} catch { return null; }
} catch (e) {
reportUploadError(e instanceof Error && e.message ? `Upload failed: ${e.message}` : 'Upload failed: could not reach the server');
return null;
}
}
/** List assets uploaded for the current site, optionally filtered by
@@ -145,3 +145,52 @@ describe('composed preview: animation head gate covers header+body+footer', () =
expect(html).not.toContain("querySelectorAll('[data-animation]')");
});
});
/**
* Backend final-fix wave: generateCompiledHTML now (a) scans headCode for preset
* fonts and (b) gates the animation CSS on the composed header+body+footer.
* These two heads were printed by the BACKEND's generateCompiledHTML() (read-only
* sb-backend worktree) for the inputs below and are pinned verbatim; the Preview
* (exportToHtml / exportComposedPreviewHtml) must emit the same bytes.
* E: title 'Notes', body '<div></div>', headCode '<style>.x{font-family:Merriweather,serif}</style>',
* default design, canonical https://example.com/notes
* F: title 'Home', composed '<header data-animation="fade-in">H</header><div></div>' (animation
* only in the header), default design, canonical https://example.com/
*/
const BACKEND_PINNED_HEAD: Record<string, string> = {
E_headcode_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Notes</title>\n <meta property=\"og:title\" content=\"Notes\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/notes\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <style>.x{font-family:Merriweather,serif}</style>\n</head>",
F_header_animation: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Home</title>\n <meta property=\"og:title\" content=\"Home\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n</head>",
};
describe('Preview head === backend head for the head-code font scan and header/footer animation gate', () => {
test('E: a preset font named only in head code is in the Preview fonts link', () => {
const html = exportToHtml(root({}), {
title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes',
headCode: '<style>.x{font-family:Merriweather,serif}</style>',
}).html;
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font);
});
test('E (composed path, as TopBar Preview uses it) gives the same head', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(root({}), '<div></div>', {
title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes',
headCode: '<style>.x{font-family:Merriweather,serif}</style>',
});
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font);
});
test('without head code naming it, Merriweather is NOT linked (the scan is what adds it)', () => {
const html = exportToHtml(root({}), { title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes' }).html;
expect(headOf(html)).not.toContain('family=Merriweather');
});
test('F: animation only in the header -> identical head (keyframes + noscript)', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(root({}), '<header data-animation="fade-in">H</header><div></div>', {
title: 'Home', includeFonts: true, canonicalUrl: U,
});
expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.F_header_animation);
});
});
+3 -1
View File
@@ -464,7 +464,9 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
// correct stand-in rather than skipping the block entirely.
const design = options.design || DEFAULT_SITE_DESIGN;
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, options.fontScanHtml ?? bodyHtml)}` : '';
// Head code is scanned too (a font named only in an analytics/style snippet
// still gets its link), like generateCompiledHTML's $html . $css . $headCode.
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, (options.fontScanHtml ?? bodyHtml) + (options.headCode ?? ''))}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : '';
const seoMeta = buildSeoMeta(options, title);
const tokenCss = buildTokenCss(design);
+36
View File
@@ -0,0 +1,36 @@
import { describe, test, expect, beforeEach } from 'vitest';
import { setSiteLimits, getSiteLimits, uploadSizeError, uploadFailureText, formatMegabytes } from './site-limits';
describe('site limits (server-advertised, in memory)', () => {
beforeEach(() => setSiteLimits(undefined));
test('unknown limits (older backend) never refuse a file', () => {
expect(getSiteLimits()).toEqual({ uploadMaxBytes: undefined, projectMaxBytes: undefined });
expect(uploadSizeError({ size: 10 ** 12, name: 'a.mp4' })).toBeNull();
});
test('adopts limits.upload_max_bytes / project_max_bytes from load', () => {
setSiteLimits({ upload_max_bytes: 67108864, project_max_bytes: 20971520 });
expect(getSiteLimits()).toEqual({ uploadMaxBytes: 67108864, projectMaxBytes: 20971520 });
});
test('ignores malformed values', () => {
setSiteLimits({ upload_max_bytes: '64', project_max_bytes: -1 });
expect(getSiteLimits()).toEqual({ uploadMaxBytes: undefined, projectMaxBytes: undefined });
});
test('a file over the limit is refused with a readable message; at the limit is allowed', () => {
setSiteLimits({ upload_max_bytes: 64 * 1048576 });
expect(uploadSizeError({ size: 64 * 1048576, name: 'ok.png' })).toBeNull();
const msg = uploadSizeError({ size: 64 * 1048576 + 1, name: 'big.mp4' });
expect(msg).toContain('big.mp4');
expect(msg).toContain('64 MB');
});
test('uploadFailureText prefers the server error text over the status', () => {
expect(uploadFailureText({ success: false, error: 'That file is too large. The site builder accepts files up to 64 MB.' }, 413))
.toBe('That file is too large. The site builder accepts files up to 64 MB.');
expect(uploadFailureText(null, 413)).toBe('Upload failed: 413');
expect(formatMegabytes(1572864)).toBe('1.5 MB');
});
});
+60
View File
@@ -0,0 +1,60 @@
/* ---------- Server-advertised size limits (in memory only) ----------
`load` returns `limits: { upload_max_bytes, project_max_bytes }`. They live
in a module variable so non-hook code (utils/assets, ImageBlock) can read
them; nothing is persisted. Unknown (older backend) = no client-side check. */
export interface SiteLimits {
uploadMaxBytes?: number;
projectMaxBytes?: number;
}
let current: SiteLimits = {};
const positive = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
/** Adopts the `limits` object from a load response; anything malformed is ignored. */
export function setSiteLimits(raw: unknown): void {
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
current = { uploadMaxBytes: positive(o.upload_max_bytes), projectMaxBytes: positive(o.project_max_bytes) };
}
export function getSiteLimits(): SiteLimits {
return current;
}
export function formatMegabytes(bytes: number): string {
const mb = bytes / 1048576;
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`;
}
/** A user-facing refusal when `file` is over the upload limit, else null. */
export function uploadSizeError(file: { size: number; name?: string }): string | null {
const max = current.uploadMaxBytes;
if (max === undefined || file.size <= max) return null;
return `${file.name ? `"${file.name}" is` : 'That file is'} too large (${formatMegabytes(file.size)}). The site builder accepts files up to ${formatMegabytes(max)}.`;
}
/** The message to show for a failed upload response: the server's own `error` text first. */
export function uploadFailureText(data: unknown, status: number): string {
const err = data && typeof data === 'object' ? (data as { error?: unknown; message?: unknown }) : null;
const text = (typeof err?.error === 'string' && err.error.trim()) || (typeof err?.message === 'string' && err.message.trim());
return text || `Upload failed: ${status}`;
}
/* ---------- Upload error bus ----------
Upload helpers (utils/assets, used by ImageBlock and every StylePanel) return
`string | null` and have no React context. They report WHY an upload failed
here; NoticeHost bridges it to the notice system. */
type UploadErrorListener = (message: string) => void;
const uploadErrorListeners = new Set<UploadErrorListener>();
export function onUploadError(listener: UploadErrorListener): () => void {
uploadErrorListeners.add(listener);
return () => { uploadErrorListeners.delete(listener); };
}
export function reportUploadError(message: string): void {
if (uploadErrorListeners.size === 0) { console.error('[upload]', message); return; }
uploadErrorListeners.forEach((l) => l(message));
}