diff --git a/craft/src/hooks/useAssets.test.tsx b/craft/src/hooks/useAssets.test.tsx
new file mode 100644
index 0000000..169267c
--- /dev/null
+++ b/craft/src/hooks/useAssets.test.tsx
@@ -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
; let fetchMock: ReturnType;
+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(); });
+});
+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');
+ });
+});
diff --git a/craft/src/hooks/useAssets.ts b/craft/src/hooks/useAssets.ts
index 524c872..3063c2e 100644
--- a/craft/src/hooks/useAssets.ts
+++ b/craft/src/hooks/useAssets.ts
@@ -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 => {
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,
diff --git a/craft/src/hooks/useStockPhotos.test.ts b/craft/src/hooks/useStockPhotos.test.ts
index da1c2e0..d066b94 100644
--- a/craft/src/hooks/useStockPhotos.test.ts
+++ b/craft/src/hooks/useStockPhotos.test.ts
@@ -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) => {
diff --git a/craft/src/hooks/useWhpApi.backend-contract.test.tsx b/craft/src/hooks/useWhpApi.backend-contract.test.tsx
new file mode 100644
index 0000000..e99990a
--- /dev/null
+++ b/craft/src/hooks/useWhpApi.backend-contract.test.tsx
@@ -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) => {
+ 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;
+let api: ReturnType;
+let guard: ReturnType;
+let notices: ReturnType;
+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(
+
+
+
+
+
+ ,
+ );
+ });
+ await flush();
+}
+const unmount = () => { act(() => { root.unmount(); }); container.remove(); };
+
+let loadBody: Record;
+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([]);
+ });
+});
diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts
index 4a12374..6586fec 100644
--- a/craft/src/hooks/useWhpApi.ts
+++ b/craft/src/hooks/useWhpApi.ts
@@ -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) {
diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx
index ba66996..7f26212 100644
--- a/craft/src/panels/topbar/TopBar.test.tsx
+++ b/craft/src/panels/topbar/TopBar.test.tsx
@@ -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();
diff --git a/craft/src/state/SaveGuardContext.tsx b/craft/src/state/SaveGuardContext.tsx
index 15ff42e..3404c89 100644
--- a/craft/src/state/SaveGuardContext.tsx
+++ b/craft/src/state/SaveGuardContext.tsx
@@ -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(defaultValue);
@@ -102,6 +116,7 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable f
const revRef = useRef(undefined);
const [neverSaved, setNeverSaved] = useState(false);
const saveInFlight = useRef | 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 {children};
};
diff --git a/craft/src/utils/assets.test.ts b/craft/src/utils/assets.test.ts
index 69bcc7c..f9db78f 100644
--- a/craft/src/utils/assets.test.ts
+++ b/craft/src/utils/assets.test.ts
@@ -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());
diff --git a/craft/src/utils/assets.ts b/craft/src/utils/assets.ts
index 4d78276..241edea 100644
--- a/craft/src/utils/assets.ts
+++ b/craft/src/utils/assets.ts
@@ -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 {
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 {
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
diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts
index 54d7e90..1e2ec4a 100644
--- a/craft/src/utils/html-export.head-parity.test.ts
+++ b/craft/src/utils/html-export.head-parity.test.ts
@@ -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 '', headCode '',
+ * default design, canonical https://example.com/notes
+ * F: title 'Home', composed '' (animation
+ * only in the header), default design, canonical https://example.com/
+ */
+const BACKEND_PINNED_HEAD: Record = {
+ E_headcode_font: "\n\n\n \n \n Notes\n \n \n \n \n \n \n \n \n",
+ F_header_animation: "\n\n\n \n \n Home\n \n \n \n \n \n \n \n \n",
+};
+
+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: '',
+ }).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({}), '', {
+ title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes',
+ headCode: '',
+ });
+ 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({}), '', {
+ title: 'Home', includeFonts: true, canonicalUrl: U,
+ });
+ expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.F_header_animation);
+ });
+});
diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts
index 25d46a5..156cb7e 100644
--- a/craft/src/utils/html-export.ts
+++ b/craft/src/utils/html-export.ts
@@ -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);
diff --git a/craft/src/utils/site-limits.test.ts b/craft/src/utils/site-limits.test.ts
new file mode 100644
index 0000000..82a9d8b
--- /dev/null
+++ b/craft/src/utils/site-limits.test.ts
@@ -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');
+ });
+});
diff --git a/craft/src/utils/site-limits.ts b/craft/src/utils/site-limits.ts
new file mode 100644
index 0000000..22ac8d0
--- /dev/null
+++ b/craft/src/utils/site-limits.ts
@@ -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) : {};
+ 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();
+
+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));
+}