feat(site-builder): pre-publish checklist and canonical link
Publish now runs client-side checks (image alt, meta description, title length, H1, empty links, contrast, noindex landing, empty page) over the current state and shows a grouped checklist with Fix links and "Publish anyway". Adds ExportOptions.canonicalUrl -> <link rel="canonical"> after the favicon line in both Preview and export. Refs #54
This commit is contained in:
1 parent
5430f13877
commit
c0fd183c11
12 files changed
+913
-13
No files matched your search
@@ -204,7 +204,7 @@ describe('conflict modal', () => {
|
||||
loadedThen('r1', [conflict409]);
|
||||
mount();
|
||||
await flush();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await confirmChecklist();
|
||||
await flush();
|
||||
expect(q('[data-testid="conflict-modal"]')).not.toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(([u]) => String(u).includes('action=publish'))).toHaveLength(0);
|
||||
@@ -264,3 +264,10 @@ describe('revision and overwrite edge cases', () => {
|
||||
expect(saveBody(2).base_rev).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
/** #54: the pre-publish checklist opens for these fixtures (an empty Home with no
|
||||
* meta description); these tests are about the publish path, so confirm it. */
|
||||
async function confirmChecklist() {
|
||||
const b = q('[data-testid="publish-checklist-confirm"]') as HTMLElement | null;
|
||||
if (b) await act(async () => { b.click(); });
|
||||
}
|
||||
@@ -138,7 +138,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const edit = () => act(() => { guard.markDirty(); });
|
||||
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); };
|
||||
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await confirmChecklist(); await flush(); };
|
||||
|
||||
describe('saves are serialised', () => {
|
||||
test('a save requested during an in-flight publish waits, then saves with the rev the first save returned', async () => {
|
||||
@@ -235,3 +235,10 @@ describe('autosave timer', () => {
|
||||
expect(calls('save').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** #54: the pre-publish checklist opens for these fixtures (an empty Home with no
|
||||
* meta description); these tests are about the publish path, so confirm it. */
|
||||
async function confirmChecklist() {
|
||||
const b = q('[data-testid="publish-checklist-confirm"]') as HTMLElement | null;
|
||||
if (b) await act(async () => { b.click(); });
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise
|
||||
const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`));
|
||||
const edit = () => act(() => { guard.markDirty(); });
|
||||
const clickSave = async () => { await act(async () => { q('button[aria-label="Save Draft"]')!.click(); }); await flush(); };
|
||||
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await flush(); };
|
||||
const clickPublish = async () => { await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await confirmChecklist(); await flush(); };
|
||||
|
||||
function respondWith(handlers: { save?: unknown[]; publish?: unknown[] }) {
|
||||
const save = [...(handlers.save ?? [])];
|
||||
@@ -368,7 +368,7 @@ describe('publish racing an edit', () => {
|
||||
});
|
||||
await mount();
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await confirmChecklist();
|
||||
await flush();
|
||||
expect(calls('publish')).toHaveLength(1);
|
||||
|
||||
@@ -392,7 +392,7 @@ describe('publish racing an edit', () => {
|
||||
});
|
||||
await mount();
|
||||
edit();
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); });
|
||||
await act(async () => { q('button[aria-label="Publish to live site"]')!.click(); }); await confirmChecklist();
|
||||
await flush();
|
||||
const savesBefore = calls('save').length; // the publish's own save
|
||||
await act(async () => { vi.advanceTimersByTime(30_000); });
|
||||
@@ -433,3 +433,10 @@ describe('populated project load', () => {
|
||||
expect(pill()).toBe('unsaved');
|
||||
});
|
||||
});
|
||||
|
||||
/** #54: the pre-publish checklist opens for these fixtures (an empty Home with no
|
||||
* meta description); these tests are about the publish path, so confirm it. */
|
||||
async function confirmChecklist() {
|
||||
const b = q('[data-testid="publish-checklist-confirm"]') as HTMLElement | null;
|
||||
if (b) await act(async () => { b.click(); });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React, { useEffect } from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { PageProvider, usePages } from '../../state/PageContext';
|
||||
import { PublishChecklistModal } from './PublishChecklistModal';
|
||||
import { runPublishChecks } from '../../utils/publish-checks';
|
||||
import { DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
|
||||
/** Real <Editor>/<Frame> + the real PageProvider: Fix must switch the page via
|
||||
* the real switchPage (deserialize on a timeout) and then select a node that
|
||||
* only exists after that deserialize. */
|
||||
|
||||
const stateWith = (nodes: Record<string, any>) => JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: Object.keys(nodes), linkedNodes: {} },
|
||||
...Object.fromEntries(Object.entries(nodes).map(([id, n]) => [id, { displayName: id, custom: {}, hidden: false, isCanvas: false, nodes: [], linkedNodes: {}, parent: 'ROOT', ...n }])),
|
||||
});
|
||||
const H1 = { type: { resolvedName: 'Heading' }, props: { level: 'h1', text: 'Hi', style: {} } };
|
||||
const IMG = { type: { resolvedName: 'ImageBlock' }, props: { src: 'assets/a.png', alt: '' } };
|
||||
|
||||
const HOME = stateWith({ h: H1, homeImg: IMG });
|
||||
const ABOUT = stateWith({ h2: H1, aboutImg: IMG });
|
||||
const SEO = { metaDescription: 'd' };
|
||||
const PAGES = [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: HOME, seo: SEO },
|
||||
{ id: 'about', name: 'About', slug: 'about', craftState: ABOUT, seo: SEO },
|
||||
];
|
||||
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => { h?.unmount(); h = null; });
|
||||
|
||||
const Seed: React.FC<{ onConfirm: () => void; onCancel: () => void }> = ({ onConfirm, onCancel }) => {
|
||||
const { setPagesCraftState, pages, activePageId } = usePages();
|
||||
const seeded = pages.length === 2;
|
||||
useEffect(() => { if (!seeded) setPagesCraftState(PAGES); }, [seeded, setPagesCraftState]);
|
||||
if (!seeded) return null;
|
||||
const checks = runPublishChecks({ pages, design: DEFAULT_SITE_DESIGN });
|
||||
return (
|
||||
<>
|
||||
<span data-testid="active">{activePageId}</span>
|
||||
<PublishChecklistModal checks={checks} onConfirm={onConfirm} onCancel={onCancel} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const tick = async () => { await h!.act(async () => { await new Promise((r) => setTimeout(r, 30)); }); };
|
||||
const q = (id: string, i = 0) => h!.container.ownerDocument.querySelectorAll<HTMLElement>(`[data-testid="${id}"]`)[i];
|
||||
const click = async (el: HTMLElement) => { await h!.act(async () => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); };
|
||||
const selected = () => h!.query.getEvent('selected').first();
|
||||
|
||||
function mount(onConfirm = vi.fn(), onCancel = vi.fn()) {
|
||||
h = renderEditorHarness({ initialState: HOME });
|
||||
h.mountChild(<PageProvider><Seed onConfirm={onConfirm} onCancel={onCancel} /></PageProvider>);
|
||||
return { onConfirm, onCancel };
|
||||
}
|
||||
|
||||
describe('PublishChecklistModal', () => {
|
||||
test('groups items under their page names', async () => {
|
||||
mount();
|
||||
await tick();
|
||||
const heads = Array.from(h!.container.ownerDocument.querySelectorAll('.publish-checklist-group h4')).map((e) => e.textContent);
|
||||
expect(heads).toEqual(['Home', 'About']);
|
||||
expect(h!.container.ownerDocument.querySelectorAll('[data-testid="publish-check"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Fix on the alt warning of ANOTHER page switches to it and selects the image node', async () => {
|
||||
const { onCancel } = mount();
|
||||
await tick();
|
||||
expect(q('active').textContent).toBe('home');
|
||||
const aboutFix = h!.container.ownerDocument.querySelector<HTMLElement>('[data-check-id="alt:about:aboutImg"] [data-testid="publish-check-fix"]')!;
|
||||
await click(aboutFix);
|
||||
await tick();
|
||||
expect(q('active').textContent).toBe('about');
|
||||
expect(h!.query.node('aboutImg').get()).toBeTruthy();
|
||||
expect(selected()).toBe('aboutImg');
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Fix on the active page selects immediately, without switching', async () => {
|
||||
mount();
|
||||
await tick();
|
||||
const homeFix = h!.container.ownerDocument.querySelector<HTMLElement>('[data-check-id="alt:home:homeImg"] [data-testid="publish-check-fix"]')!;
|
||||
await click(homeFix);
|
||||
expect(q('active').textContent).toBe('home');
|
||||
expect(selected()).toBe('homeImg');
|
||||
});
|
||||
|
||||
test('Publish anyway calls onConfirm; Cancel calls onCancel', async () => {
|
||||
const { onConfirm, onCancel } = mount();
|
||||
await tick();
|
||||
await click(q('publish-checklist-confirm'));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
await click(q('publish-checklist-cancel'));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('items with no fix target (an empty page) render without a Fix button', async () => {
|
||||
h = renderEditorHarness({ initialState: HOME });
|
||||
h.mountChild(
|
||||
<PageProvider>
|
||||
<PublishChecklistModal checks={[{ id: 'empty:x', severity: 'warn', message: 'X is empty' }]} onConfirm={() => {}} onCancel={() => {}} />
|
||||
</PageProvider>,
|
||||
);
|
||||
expect(h.container.ownerDocument.querySelectorAll('[data-testid="publish-check-fix"]')).toHaveLength(0);
|
||||
expect(h.container.ownerDocument.body.textContent).toContain('X is empty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import type { Check } from '../../utils/publish-checks';
|
||||
|
||||
export interface PublishChecklistModalProps {
|
||||
checks: Check[];
|
||||
/** "Publish anyway": the caller publishes with the checks skipped. */
|
||||
/** Backend has reported sitemap.xml / robots.txt generation on a past publish. */
|
||||
showGeneratedNote?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Pre-publish checklist (#54). Every item is a warning or a note, so
|
||||
* publishing is always one click away; "Fix" jumps to the page/node and
|
||||
* closes the list so the customer can edit. */
|
||||
export const PublishChecklistModal: React.FC<PublishChecklistModalProps> = ({ checks, showGeneratedNote, onConfirm, onCancel }) => {
|
||||
const { actions } = useEditor();
|
||||
const { pages, headerPage, footerPage, activePageId, switchPage } = usePages();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
|
||||
const nameOf = (pageId: string | undefined) => {
|
||||
if (!pageId) return 'Site';
|
||||
return [...pages, headerPage, footerPage].find((p) => p.id === pageId)?.name ?? 'Site';
|
||||
};
|
||||
|
||||
const groups: { pageId: string | undefined; items: Check[] }[] = [];
|
||||
for (const c of checks) {
|
||||
const g = groups.find((x) => x.pageId === c.pageId);
|
||||
if (g) g.items.push(c); else groups.push({ pageId: c.pageId, items: [c] });
|
||||
}
|
||||
|
||||
const fix = (c: Check) => {
|
||||
if (c.pageId && c.pageId !== activePageId) {
|
||||
switchPage(c.pageId);
|
||||
// switchPage deserializes the target on a setTimeout(0) (PageContext
|
||||
// loadState); queued after it, this runs once the node exists.
|
||||
if (c.nodeId) {
|
||||
const nodeId = c.nodeId;
|
||||
timer.current = setTimeout(() => { try { actions.selectNode(nodeId); } catch { /* node gone */ } }, 0);
|
||||
}
|
||||
} else if (c.nodeId) {
|
||||
try { actions.selectNode(c.nodeId); } catch { /* node gone */ }
|
||||
}
|
||||
// Close so the modal doesn't cover the node about to be edited. Detach the
|
||||
// pending select from the unmount cleanup above: it must still fire.
|
||||
timer.current = null;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const canFix = (c: Check) => !!c.pageId && (c.fix === 'select' || c.fix === 'page-settings');
|
||||
|
||||
return (
|
||||
<div className="conflict-backdrop" data-testid="publish-checklist">
|
||||
<div className="conflict-modal publish-checklist" role="dialog" aria-modal="true" aria-labelledby="publish-checklist-title">
|
||||
<h3 id="publish-checklist-title">Before you publish</h3>
|
||||
<p>{checks.length === 1 ? '1 thing' : `${checks.length} things`} worth a look. None of them stop you from publishing.</p>
|
||||
<div className="publish-checklist-body">
|
||||
{groups.map((g) => (
|
||||
<section key={g.pageId ?? 'site'} className="publish-checklist-group">
|
||||
<h4>{nameOf(g.pageId)}</h4>
|
||||
<ul>
|
||||
{g.items.map((c) => (
|
||||
<li key={c.id} className={`publish-check publish-check-${c.severity}`} data-testid="publish-check" data-check-id={c.id}>
|
||||
<span className="publish-check-msg">{c.message}</span>
|
||||
{canFix(c) && (
|
||||
<button type="button" className="publish-check-fix" onClick={() => fix(c)} data-testid="publish-check-fix">
|
||||
Fix
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{showGeneratedNote && (
|
||||
<p className="publish-checklist-note" data-testid="publish-checklist-generated">sitemap.xml and robots.txt will be generated.</p>
|
||||
)}
|
||||
<div className="conflict-modal-actions">
|
||||
<button type="button" onClick={onCancel} data-testid="publish-checklist-cancel">Cancel</button>
|
||||
<button type="button" onClick={onConfirm} data-testid="publish-checklist-confirm">Publish anyway</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,10 +32,23 @@ import { act } from 'react-dom/test-utils';
|
||||
* needs more than that.
|
||||
*/
|
||||
|
||||
// #54: what the pre-publish checks see. Default (no pages) means no checks, so
|
||||
// every pre-existing test publishes directly.
|
||||
const checkFixture: { pages: any[]; liveState: string; activePageId: string } = { pages: [], liveState: '{}', activePageId: 'home' };
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({
|
||||
pages: checkFixture.pages,
|
||||
headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null },
|
||||
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: null },
|
||||
activePageId: checkFixture.activePageId,
|
||||
switchPage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||
const query = {
|
||||
serialize: () => '{}',
|
||||
serialize: () => checkFixture.liveState,
|
||||
history: { canUndo: () => false, canRedo: () => false },
|
||||
};
|
||||
const actions = { history: { undo: vi.fn(), redo: vi.fn() } };
|
||||
@@ -114,6 +127,9 @@ afterEach(() => {
|
||||
}
|
||||
publishMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
checkFixture.pages = [];
|
||||
checkFixture.liveState = '{}';
|
||||
checkFixture.activePageId = 'home';
|
||||
});
|
||||
|
||||
describe('TopBar publish-warnings wiring', () => {
|
||||
@@ -214,3 +230,88 @@ describe('TopBar device switcher reads/writes DeviceContext (W0-breakpoints)', (
|
||||
expect(tablet.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/** #54: Publish runs the checklist first; the modal gates publish() until confirmed. */
|
||||
describe('TopBar pre-publish checklist', () => {
|
||||
const stateWith = (nodes: Record<string, any>) => JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: Object.keys(nodes), linkedNodes: {}, parent: null },
|
||||
...Object.fromEntries(Object.entries(nodes).map(([id, n]) => [id, { nodes: [], linkedNodes: {}, parent: 'ROOT', ...n }])),
|
||||
});
|
||||
const H1 = { type: { resolvedName: 'Heading' }, props: { level: 'h1', text: 'Hi', style: {} } };
|
||||
const BAD_IMG = { type: { resolvedName: 'ImageBlock' }, props: { src: 'assets/a.png', alt: '' } };
|
||||
const page = (craftState: string) => ({ id: 'p1', name: 'Home', slug: 'index', seo: { metaDescription: 'd' }, craftState });
|
||||
const modal = () => container.querySelector('[data-testid="publish-checklist"]');
|
||||
const q = (id: string) => container.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
|
||||
const click = async (el: Element) => {
|
||||
await act(async () => {
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
test('with warnings: modal opens and publish() is NOT called until Publish anyway', async () => {
|
||||
publishMock.mockResolvedValue({ success: true });
|
||||
checkFixture.pages = [page(stateWith({ h: H1, img: BAD_IMG }))];
|
||||
checkFixture.activePageId = 'other';
|
||||
render();
|
||||
await clickPublish();
|
||||
expect(modal()).not.toBeNull();
|
||||
expect(container.querySelectorAll('[data-testid="publish-check"]')).toHaveLength(1);
|
||||
expect(publishMock).not.toHaveBeenCalled();
|
||||
|
||||
await click(q('publish-checklist-confirm'));
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
expect(modal()).toBeNull();
|
||||
});
|
||||
|
||||
test('Cancel closes the modal and never publishes', async () => {
|
||||
checkFixture.pages = [page(stateWith({ h: H1, img: BAD_IMG }))];
|
||||
checkFixture.activePageId = 'other';
|
||||
render();
|
||||
await clickPublish();
|
||||
await click(q('publish-checklist-cancel'));
|
||||
expect(modal()).toBeNull();
|
||||
expect(publishMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('with nothing to flag: publishes directly, no modal', async () => {
|
||||
publishMock.mockResolvedValue({ success: true });
|
||||
checkFixture.pages = [page(stateWith({ h: H1 }))];
|
||||
checkFixture.activePageId = 'other';
|
||||
render();
|
||||
await clickPublish();
|
||||
expect(modal()).toBeNull();
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('checks the LIVE canvas for the active page, not its stale stored state', async () => {
|
||||
publishMock.mockResolvedValue({ success: true });
|
||||
checkFixture.pages = [page(stateWith({ h: H1 }))]; // stored: clean
|
||||
checkFixture.activePageId = 'p1';
|
||||
checkFixture.liveState = stateWith({ h: H1, img: BAD_IMG }); // canvas: unsaved bad image
|
||||
render();
|
||||
await clickPublish();
|
||||
expect(modal()).not.toBeNull();
|
||||
expect(publishMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a failed save after confirming still surfaces as a publish failure (gate does not swallow it)', async () => {
|
||||
publishMock.mockResolvedValue({ success: false, failedStep: 'save', message: 'Publish cancelled: the save before publishing did not complete.' });
|
||||
checkFixture.pages = [page(stateWith({ h: H1, img: BAD_IMG }))];
|
||||
checkFixture.activePageId = 'other';
|
||||
render();
|
||||
await clickPublish();
|
||||
await click(q('publish-checklist-confirm'));
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain('did not complete');
|
||||
});
|
||||
|
||||
test('response.generated adds an info line to the post-publish banner', async () => {
|
||||
publishMock.mockResolvedValue({ success: true, generated: ['sitemap.xml', 'robots.txt'] });
|
||||
render();
|
||||
await clickPublish();
|
||||
expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain('Generated sitemap.xml and robots.txt.');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import { PublishWarnings } from './PublishWarnings';
|
||||
import { EmptySaveBanner } from './EmptySaveBanner';
|
||||
import { ConflictModal } from './ConflictModal';
|
||||
import { ErrorNotice } from './ErrorNotice';
|
||||
import { PublishChecklistModal } from './PublishChecklistModal';
|
||||
import { runPublishChecks, Check } from '../../utils/publish-checks';
|
||||
import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
|
||||
import { useSaveGuard } from '../../state/SaveGuardContext';
|
||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
@@ -53,6 +55,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
const [errorNotice, setErrorNotice] = useState<string | null>(null);
|
||||
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
// Pre-publish checklist (#54): non-null while the modal is up.
|
||||
const [pendingChecks, setPendingChecks] = useState<Check[] | null>(null);
|
||||
// Set once a publish response reports sitemap/robots generation (backend support).
|
||||
const [backendGenerates, setBackendGenerates] = useState(false);
|
||||
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
||||
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
||||
// otherwise identical for both the desktop and mobile branches below.
|
||||
@@ -143,8 +149,37 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
|
||||
const handleSave = runSave;
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
/** Checks over the CURRENT state: the active page/zone is read live from the
|
||||
* canvas, everything else from its stored craftState. Never throws -- a
|
||||
* checker bug must not stand between a customer and Publish. */
|
||||
const computeChecks = useCallback((): Check[] => {
|
||||
try {
|
||||
const live = query.serialize();
|
||||
const pick = (id: string, stored: string | null) => (id === activePageId ? live : stored);
|
||||
return runPublishChecks({
|
||||
pages: pages.map((p) => ({ id: p.id, name: p.name, slug: p.slug, seo: p.seo, craftState: pick(p.id, p.craftState) })),
|
||||
zones: [headerPage, footerPage].map((z) => ({ id: z.id, name: z.name, craftState: pick(z.id, z.craftState) })),
|
||||
design,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Publish checks failed:', e);
|
||||
return [];
|
||||
}
|
||||
}, [query, pages, headerPage, footerPage, activePageId, design]);
|
||||
|
||||
// `opts` is read as `=== true` because this is also an onClick handler (the
|
||||
// first argument is then a MouseEvent).
|
||||
const handlePublish = useCallback(async (opts?: { skipChecks?: boolean }) => {
|
||||
if (!canSave) return;
|
||||
if (opts?.skipChecks !== true) {
|
||||
if (pendingChecks) return;
|
||||
const checks = computeChecks();
|
||||
if (checks.length > 0) {
|
||||
setPendingChecks(checks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPendingChecks(null);
|
||||
// Anything edited after this point is not in what we are about to publish.
|
||||
const versionAtStart = getVersion();
|
||||
publishingRef.current = true;
|
||||
@@ -167,7 +202,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
// The site published; these are fixable problems, not failures. Held
|
||||
// independently of the status pill so a later edit doesn't take the
|
||||
// warning down with it.
|
||||
setPublishWarnings(Array.isArray(result.warnings) ? result.warnings : []);
|
||||
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||
const generated = Array.isArray(result.generated) ? result.generated.filter((g): g is string => typeof g === 'string') : [];
|
||||
setBackendGenerates(generated.includes('sitemap.xml'));
|
||||
setPublishWarnings(generated.length ? [...warnings, `Generated ${generated.join(' and ')}.`] : warnings);
|
||||
} else if (result) {
|
||||
// The save before the publish failed: that is a save failure, and
|
||||
// nothing went live.
|
||||
@@ -181,7 +219,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
publishingRef.current = false;
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [publish, canSave, getVersion, setEmptyRefusal]);
|
||||
}, [publish, canSave, getVersion, setEmptyRefusal, pendingChecks, computeChecks]);
|
||||
|
||||
// "Keep mine": arm force_overwrite so it survives an empty-overwrite refusal
|
||||
// followed by "Save anyway", then re-save. runSave closes the modal on success.
|
||||
@@ -200,6 +238,15 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const checklistModal = pendingChecks ? (
|
||||
<PublishChecklistModal
|
||||
checks={pendingChecks}
|
||||
showGeneratedNote={backendGenerates}
|
||||
onConfirm={() => { void handlePublish({ skipChecks: true }); }}
|
||||
onCancel={() => setPendingChecks(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const emptyBanner = emptyRefusal ? (
|
||||
<EmptySaveBanner
|
||||
onSaveAnyway={() => { void handleSave({ forceEmpty: true }); }}
|
||||
@@ -271,6 +318,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
twitterCard: seo?.twitterCard,
|
||||
noindex: seo?.noindex,
|
||||
favicon: design.favicon,
|
||||
// Same URL the backend emits at publish, so Preview head === published head.
|
||||
canonicalUrl: isWHP && whpConfig?.siteDomain && activePage
|
||||
? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}`
|
||||
: undefined,
|
||||
design,
|
||||
});
|
||||
|
||||
@@ -295,7 +346,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
} catch (e) {
|
||||
console.error('Preview failed:', e);
|
||||
}
|
||||
}, [query, headerPage, footerPage, whpConfig, design, pages, activePageId]);
|
||||
}, [query, headerPage, footerPage, whpConfig, isWHP, design, pages, activePageId]);
|
||||
|
||||
// Site-level commands: the keyboard layer (Cmd+S / Cmd+Shift+P / Cmd+P), the
|
||||
// "..." command menu and the shortcuts cheat sheet all read these. Re-registered
|
||||
@@ -322,6 +373,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
{checklistModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
@@ -369,7 +421,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn icon-only publish"
|
||||
onClick={handlePublish}
|
||||
onClick={() => { void handlePublish(); }}
|
||||
disabled={publishing || saving || !canSave}
|
||||
aria-label="Publish to live site"
|
||||
data-tooltip="Publish"
|
||||
@@ -415,6 +467,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||
{emptyBanner}
|
||||
{conflictModal}
|
||||
{checklistModal}
|
||||
<ErrorNotice message={errorNotice} onDismiss={() => setErrorNotice(null)} />
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
@@ -517,7 +570,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
onClick={() => { void handlePublish(); }}
|
||||
disabled={publishing || saving || !canSave}
|
||||
title="Publish to live site"
|
||||
aria-label="Publish to live site"
|
||||
|
||||
@@ -2731,3 +2731,14 @@ body {
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* #54: pre-publish checklist */
|
||||
.publish-checklist { max-width: 520px; width: 100%; max-height: 80vh; display: flex; flex-direction: column; }
|
||||
.publish-checklist-body { overflow-y: auto; margin: 4px 0; }
|
||||
.publish-checklist-group h4 { margin: 10px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
|
||||
.publish-checklist-group ul { list-style: none; margin: 0; padding: 0; }
|
||||
.publish-check { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 6px 0; border-top: 1px solid #334155; }
|
||||
.publish-check-warn .publish-check-msg::before { content: '\26A0\00a0'; color: #fbbf24; }
|
||||
.publish-check-info .publish-check-msg::before { content: '\2139\00a0'; color: #7dd3fc; }
|
||||
.publish-check-fix { flex: none; background: none; border: 1px solid #94a3b8; border-radius: 4px; color: inherit; cursor: pointer; padding: 2px 10px; }
|
||||
.publish-checklist-note { margin: 8px 0 0; color: #94a3b8; font-size: 12px; }
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildAnimationScript,
|
||||
ExportOptions,
|
||||
injectAttrs,
|
||||
buildSeoMeta,
|
||||
RESPONSIVE_CSS_MINIFIED,
|
||||
VISIBILITY_CSS_MINIFIED,
|
||||
} from './html-export';
|
||||
@@ -738,3 +739,37 @@ describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
|
||||
expect(html).toContain('</style><a href="#"');
|
||||
});
|
||||
});
|
||||
|
||||
/** #54: `<link rel="canonical">`. The backend `generateCompiledHTML` must emit this exact line. */
|
||||
describe('#54: canonical link in <head>', () => {
|
||||
const STATE = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
const exp = (o: ExportOptions) => exportToHtml(STATE, o).html;
|
||||
|
||||
test('emitted only when provided, as the exact line', () => {
|
||||
expect(exp({ title: 'P', canonicalUrl: 'https://example.com/about' })).toContain('<link rel="canonical" href="https://example.com/about">');
|
||||
expect(exp({ title: 'P', canonicalUrl: 'https://example.com/' })).toContain('<link rel="canonical" href="https://example.com/">');
|
||||
expect(exp({ title: 'P' })).not.toContain('canonical');
|
||||
expect(exp({ title: 'P', canonicalUrl: '' })).not.toContain('canonical');
|
||||
});
|
||||
|
||||
test('without the option the document is byte-identical to one built with canonicalUrl undefined', () => {
|
||||
const base = { title: 'P', favicon: '/f.png', description: 'd' };
|
||||
expect(exp(base)).toBe(exp({ ...base, canonicalUrl: undefined }));
|
||||
expect(exp(base)).not.toContain('<link rel="canonical"');
|
||||
});
|
||||
|
||||
test('placed directly after the favicon line, and after it only', () => {
|
||||
const block = buildSeoMeta({ title: 'P', favicon: '/f.png', canonicalUrl: 'https://e.com/x' }, 'P');
|
||||
expect(block.endsWith('<link rel="icon" href="/f.png">\n <link rel="canonical" href="https://e.com/x">')).toBe(true);
|
||||
// No favicon: canonical is still last.
|
||||
expect(buildSeoMeta({ canonicalUrl: 'https://e.com/x' }, 'P').endsWith('<link rel="canonical" href="https://e.com/x">')).toBe(true);
|
||||
});
|
||||
|
||||
test('attribute value is escaped; non-http(s) URLs are dropped', () => {
|
||||
expect(buildSeoMeta({ canonicalUrl: 'https://e.com/a"b<c>&d' }, 'P')).toContain('<link rel="canonical" href="https://e.com/a"b<c>&d">');
|
||||
expect(buildSeoMeta({ canonicalUrl: 'javascript:alert(1)' }, 'P')).not.toContain('canonical');
|
||||
expect(buildSeoMeta({ canonicalUrl: '/relative' }, 'P')).not.toContain('canonical');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@ export interface ExportOptions {
|
||||
noindex?: boolean;
|
||||
/** SiteDesign.favicon -- site-wide, one per site. */
|
||||
favicon?: string;
|
||||
/** Absolute page URL for `<link rel="canonical">` (#54): `https://<fqdn>/` for the
|
||||
* landing page, `https://<fqdn>/<slug>` otherwise (no `.html`). Omitted => no tag. */
|
||||
canonicalUrl?: string;
|
||||
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
|
||||
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
|
||||
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
|
||||
@@ -291,7 +294,7 @@ export function buildTokenCss(design: SiteDesign): string {
|
||||
|
||||
/**
|
||||
* Builds the ordered SEO/meta tag block from `<meta name="robots">` (only
|
||||
* when noindex) through the favicon `<link>` -- everything between `<title>`
|
||||
* when noindex) through the favicon and canonical `<link>`s -- everything between `<title>`
|
||||
* and the Google Fonts link in the contract §4 order. `title` must already
|
||||
* be the FULLY RESOLVED title (i.e. `seo.metaTitle || page.name`) -- the
|
||||
* og:title fallback chain in the contract (`ogTitle || metaTitle || TITLE`)
|
||||
@@ -338,6 +341,12 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
|
||||
lines.push(`<link rel="icon" href="${escapeAttr(favicon)}">`);
|
||||
}
|
||||
|
||||
// Absolute http(s) only: anything else would be a broken canonical.
|
||||
const canonical = options.canonicalUrl && /^https?:\/\//i.test(options.canonicalUrl) ? options.canonicalUrl : '';
|
||||
if (canonical) {
|
||||
lines.push(`<link rel="canonical" href="${escapeAttr(canonical)}">`);
|
||||
}
|
||||
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { runPublishChecks, parseColor, contrastRatio, MAX_CHECKS, type CheckPage } from './publish-checks';
|
||||
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
|
||||
|
||||
type N = { type: string; props?: any; children?: string[] };
|
||||
|
||||
/** Builds a SerializedNodes JSON: ROOT -> given nodes (parent links derived). */
|
||||
function state(nodes: Record<string, N>, rootChildren: string[] = Object.keys(nodes).filter((k) => !Object.values(nodes).some((n) => n.children?.includes(k)))): string {
|
||||
const out: Record<string, any> = {
|
||||
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: rootChildren, linkedNodes: {}, parent: null },
|
||||
};
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
const parent = rootChildren.includes(id) ? 'ROOT' : Object.keys(nodes).find((k) => nodes[k].children?.includes(id)) ?? 'ROOT';
|
||||
out[id] = { type: { resolvedName: n.type }, props: n.props ?? {}, nodes: n.children ?? [], linkedNodes: {}, parent };
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
const goodSeo = { metaDescription: 'A fine page.' };
|
||||
const page = (over: Partial<CheckPage> & { nodes?: Record<string, N> }): CheckPage => ({
|
||||
id: 'p1', name: 'Home', slug: 'index', seo: goodSeo,
|
||||
craftState: over.nodes ? state(over.nodes) : over.craftState ?? null,
|
||||
...(over.id ? { id: over.id } : {}), ...(over.name ? { name: over.name } : {}), ...(over.slug ? { slug: over.slug } : {}),
|
||||
...('seo' in over ? { seo: over.seo } : {}),
|
||||
});
|
||||
const h1: N = { type: 'Heading', props: { level: 'h1', text: 'Hi', style: {} } };
|
||||
const run = (pages: CheckPage[], zones?: any[]) => runPublishChecks({ pages, design: DEFAULT_SITE_DESIGN, zones });
|
||||
const ids = (cs: { id: string }[]) => cs.map((c) => c.id);
|
||||
|
||||
describe('runPublishChecks', () => {
|
||||
test('a clean page yields nothing', () => {
|
||||
expect(run([page({ nodes: { h: h1 } })])).toEqual([]);
|
||||
});
|
||||
|
||||
test('image alt: real src + empty alt warns and targets the node; alt, placeholder, empty src do not', () => {
|
||||
const cs = run([page({ nodes: {
|
||||
h: h1,
|
||||
bad: { type: 'ImageBlock', props: { src: 'assets/a.png', alt: '' } },
|
||||
ok: { type: 'ImageBlock', props: { src: 'assets/a.png', alt: 'A cat' } },
|
||||
ph: { type: 'ImageBlock', props: { src: PLACEHOLDER_SRC, alt: '' } },
|
||||
none: { type: 'ImageBlock', props: { src: '', alt: '' } },
|
||||
} })]);
|
||||
expect(cs).toHaveLength(1);
|
||||
expect(cs[0]).toMatchObject({ id: 'alt:p1:bad', severity: 'warn', pageId: 'p1', nodeId: 'bad', fix: 'select' });
|
||||
});
|
||||
|
||||
test('meta description missing warns with page-settings fix; present does not', () => {
|
||||
const cs = run([page({ nodes: { h: h1 }, seo: {} })]);
|
||||
expect(cs).toEqual([expect.objectContaining({ id: 'meta-desc:p1', fix: 'page-settings', pageId: 'p1' })]);
|
||||
expect(run([page({ nodes: { h: h1 }, seo: { metaDescription: ' ' } })])).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('title over 60 chars (metaTitle wins over name); exactly 60 is fine', () => {
|
||||
const long = 'x'.repeat(61);
|
||||
expect(ids(run([page({ nodes: { h: h1 }, seo: { ...goodSeo, metaTitle: long } })]))).toEqual(['title-long:p1']);
|
||||
expect(ids(run([page({ nodes: { h: h1 }, name: long })]))).toEqual(['title-long:p1']);
|
||||
expect(run([page({ nodes: { h: h1 }, seo: { ...goodSeo, metaTitle: 'x'.repeat(60) } })])).toEqual([]);
|
||||
});
|
||||
|
||||
test('H1: none warns, two warns, HeroSimple counts as one, h2 does not count', () => {
|
||||
const h2: N = { type: 'Heading', props: { level: 'h2', text: 'x', style: {} } };
|
||||
expect(ids(run([page({ nodes: { a: h2, b: h2 } })]))).toEqual(['h1-none:p1']);
|
||||
expect(ids(run([page({ nodes: { a: h1, b: h1 } })]))).toEqual(['h1-multi:p1']);
|
||||
expect(run([page({ nodes: { a: { type: 'HeroSimple', props: { heading: 'Welcome', ctas: [{ text: 'Go', href: '/x' }] } }, b: h2 } })])).toEqual([]);
|
||||
expect(ids(run([page({ nodes: { a: { type: 'HeroSimple', props: { heading: 'Welcome', ctas: [] } }, b: h1 } })]))).toEqual(['h1-multi:p1']);
|
||||
});
|
||||
|
||||
test('links: ButtonLink, Navbar/Menu items and hero CTAs with # or empty href warn; real hrefs and #anchor do not', () => {
|
||||
const cs = run([page({ nodes: {
|
||||
h: h1,
|
||||
b1: { type: 'ButtonLink', props: { text: 'Buy', href: '#' } },
|
||||
b2: { type: 'ButtonLink', props: { text: 'Ok', href: '/shop' } },
|
||||
b3: { type: 'ButtonLink', props: { text: 'Anchor', href: '#about' } },
|
||||
nav: { type: 'Navbar', props: { links: [{ text: 'Home', href: '/' }, { text: 'Blog', href: '' }] } },
|
||||
menu: { type: 'Menu', props: { links: [{ text: 'X', href: '#' }] } },
|
||||
hero: { type: 'HeroSimple', props: { heading: 'Two', ctas: [{ text: 'Start', href: '#' }] } },
|
||||
} })]);
|
||||
const linkIds = ids(cs).filter((i) => i.startsWith('link:'));
|
||||
expect(linkIds.sort()).toEqual(['link:p1:b1:btn', 'link:p1:hero:c0', 'link:p1:menu:l0', 'link:p1:nav:l1'].sort());
|
||||
});
|
||||
|
||||
test('header/footer zones: links are checked, H1 and SEO are not', () => {
|
||||
const cs = run([page({ nodes: { h: h1 } })], [
|
||||
{ id: '__header__', name: 'Header', craftState: state({ n: { type: 'Navbar', props: { links: [{ text: 'Go', href: '#' }] } } }) },
|
||||
{ id: '__footer__', name: 'Footer', craftState: null },
|
||||
]);
|
||||
expect(cs).toEqual([expect.objectContaining({ id: 'link:__header__:n:l0', pageId: '__header__', nodeId: 'n' })]);
|
||||
});
|
||||
|
||||
test('noindex on the landing page is info; on a later page is not reported', () => {
|
||||
const cs = run([
|
||||
page({ nodes: { h: h1 }, seo: { ...goodSeo, noindex: true } }),
|
||||
page({ id: 'p2', name: 'About', slug: 'about', nodes: { h: h1 }, seo: { ...goodSeo, noindex: true } }),
|
||||
]);
|
||||
expect(cs).toEqual([expect.objectContaining({ id: 'noindex-landing:p1', severity: 'info' })]);
|
||||
});
|
||||
|
||||
test('empty page: ROOT with no children, or no saved state', () => {
|
||||
expect(ids(run([page({ craftState: state({}) }), page({ id: 'p2', name: 'B', slug: 'b', craftState: null })]))).toEqual(['empty:p1', 'empty:p2']);
|
||||
});
|
||||
|
||||
test('unparseable state is treated as empty, never throws', () => {
|
||||
expect(ids(run([page({ craftState: '{nope' })]))).toEqual(['empty:p1']);
|
||||
});
|
||||
|
||||
describe('contrast', () => {
|
||||
const sec = (bg: string, child: N, extra: any = {}): Record<string, N> => ({
|
||||
s: { type: 'Section', props: { style: { backgroundColor: bg }, ...extra }, children: ['t'] },
|
||||
t: child,
|
||||
h: h1,
|
||||
});
|
||||
const text = (color: string, fontSize = '16px'): N => ({ type: 'TextBlock', props: { text: 'Hello', style: { color, fontSize } } });
|
||||
|
||||
test('maths: #ffffff on #ffffff is 1:1, #1f2937 on #ffffff passes, black on white is 21', () => {
|
||||
expect(contrastRatio(parseColor('#fff')!, parseColor('#ffffff')!)).toBeCloseTo(1, 5);
|
||||
expect(contrastRatio(parseColor('#000')!, parseColor('#fff')!)).toBeCloseTo(21, 5);
|
||||
expect(contrastRatio(parseColor('#1f2937')!, parseColor('#ffffff')!)).toBeGreaterThan(4.5);
|
||||
});
|
||||
|
||||
test('white-on-white warns; dark-on-white does not', () => {
|
||||
expect(ids(run([page({ nodes: sec('#ffffff', text('#ffffff')) })]))).toEqual(['contrast:p1:t']);
|
||||
expect(run([page({ nodes: sec('#ffffff', text('#1f2937')) })])).toEqual([]);
|
||||
});
|
||||
|
||||
test('threshold depends on size: ~3.5:1 fails at 16px but passes at 24px', () => {
|
||||
// #767676 on white is 4.54; #8a8a8a is ~3.4
|
||||
expect(ids(run([page({ nodes: sec('#ffffff', text('#8a8a8a', '16px')) })]))).toEqual(['contrast:p1:t']);
|
||||
expect(run([page({ nodes: sec('#ffffff', text('#8a8a8a', '24px')) })])).toEqual([]);
|
||||
expect(ids(run([page({ nodes: sec('#ffffff', text('#cccccc', '40px')) })]))).toEqual(['contrast:p1:t']);
|
||||
});
|
||||
|
||||
test('token colours resolve through the design (text token on bg token)', () => {
|
||||
const design = { ...DEFAULT_SITE_DESIGN, textColor: '#ffffff', backgroundColor: '#ffffff' };
|
||||
const cs = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design });
|
||||
expect(ids(cs)).toEqual(['contrast:p1:t']);
|
||||
const ok = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design: { ...design, textColor: '#111111' } });
|
||||
expect(ok).toEqual([]);
|
||||
});
|
||||
|
||||
test('transparent ancestors defer to the next one up', () => {
|
||||
const nodes: Record<string, N> = {
|
||||
outer: { type: 'Section', props: { style: { backgroundColor: '#000000' } }, children: ['inner'] },
|
||||
inner: { type: 'Container', props: { style: { backgroundColor: 'transparent' } }, children: ['t'] },
|
||||
t: text('#ffffff'),
|
||||
h: h1,
|
||||
};
|
||||
expect(run([page({ nodes })])).toEqual([]);
|
||||
nodes.outer.props.style.backgroundColor = '#ffffff';
|
||||
expect(ids(run([page({ nodes })]))).toEqual(['contrast:p1:t']);
|
||||
});
|
||||
|
||||
test('unknown / gradient / image backgrounds and unparseable colours are skipped', () => {
|
||||
expect(run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })])).toEqual([]);
|
||||
expect(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })])).toEqual([]);
|
||||
expect(run([page({ nodes: sec('#ffffff', text('inherit')) })])).toEqual([]);
|
||||
expect(run([page({ nodes: { t: text('#ffffff'), h: h1 } })])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test('cap and dedupe: 80 bad images produce MAX_CHECKS items', () => {
|
||||
const nodes: Record<string, N> = { h: h1 };
|
||||
for (let i = 0; i < 80; i++) nodes['i' + i] = { type: 'ImageBlock', props: { src: 'assets/a.png', alt: '' } };
|
||||
const cs = run([page({ nodes })]);
|
||||
expect(cs).toHaveLength(MAX_CHECKS);
|
||||
expect(new Set(ids(cs)).size).toBe(MAX_CHECKS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { SiteDesign } from '../state/SiteDesignContext';
|
||||
import type { PageSeo } from '../types';
|
||||
import { resolveTokenValue } from './tokens';
|
||||
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
|
||||
import { normalizeCtas } from '../components/sections/_cta-helpers';
|
||||
|
||||
/**
|
||||
* Pre-publish checklist (#54). Pure: runs over serialized page states
|
||||
* (`SerializedNodes` JSON) and the site design, never touches the editor.
|
||||
* Every check is a warning/info -- v1 has no hard errors, so publishing is
|
||||
* never blocked, only confirmed.
|
||||
*/
|
||||
|
||||
export type CheckSeverity = 'warn' | 'info';
|
||||
export type CheckFix = 'select' | 'page-settings' | 'site-design';
|
||||
|
||||
export interface Check {
|
||||
/** Stable and unique per finding (page + node + kind). */
|
||||
id: string;
|
||||
severity: CheckSeverity;
|
||||
message: string;
|
||||
pageId?: string;
|
||||
nodeId?: string;
|
||||
fix?: CheckFix;
|
||||
}
|
||||
|
||||
export interface CheckPage {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
seo?: PageSeo;
|
||||
craftState: string | null;
|
||||
}
|
||||
|
||||
/** Header/footer zone: not a page (no H1 / SEO checks), but its links count. */
|
||||
export interface CheckZone {
|
||||
id: string;
|
||||
name: string;
|
||||
craftState: string | null;
|
||||
}
|
||||
|
||||
export interface PublishChecksInput {
|
||||
pages: CheckPage[];
|
||||
design: SiteDesign;
|
||||
zones?: CheckZone[];
|
||||
}
|
||||
|
||||
export const MAX_CHECKS = 50;
|
||||
const TITLE_MAX = 60;
|
||||
|
||||
interface SNode {
|
||||
type?: { resolvedName?: string };
|
||||
props?: Record<string, any>;
|
||||
nodes?: string[];
|
||||
linkedNodes?: Record<string, string>;
|
||||
parent?: string | null;
|
||||
}
|
||||
type SNodes = Record<string, SNode>;
|
||||
|
||||
function parseState(raw: string | null): SNodes | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as SNodes) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Node ids reachable from ROOT, in document order. */
|
||||
function walk(nodes: SNodes): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (id: string) => {
|
||||
if (seen.has(id) || !nodes[id]) return;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
const n = nodes[id];
|
||||
for (const c of n.nodes ?? []) visit(c);
|
||||
for (const c of Object.values(n.linkedNodes ?? {})) visit(c);
|
||||
};
|
||||
visit('ROOT');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- colour / contrast ----------
|
||||
|
||||
type RGB = [number, number, number];
|
||||
|
||||
/** Hex (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba() -> opaque RGB; null for anything else (gradients, names, alpha 0). */
|
||||
export function parseColor(input: string): RGB | null {
|
||||
const v = input.trim().toLowerCase();
|
||||
let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
|
||||
if (m) {
|
||||
let h = m[1];
|
||||
if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
|
||||
if (h.length === 8 && parseInt(h.slice(6), 16) === 0) return null;
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||||
}
|
||||
m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v);
|
||||
if (m) {
|
||||
if (m[4] !== undefined) {
|
||||
const a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
|
||||
if (!(a > 0)) return null;
|
||||
}
|
||||
return [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function luminance([r, g, b]: RGB): number {
|
||||
const f = (c: number) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
/** WCAG 2.x contrast ratio between two opaque colours. */
|
||||
export function contrastRatio(a: RGB, b: RGB): number {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
function pxSize(v: unknown): number | null {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = /^([\d.]+)(px|rem|em)$/.exec(v.trim());
|
||||
if (!m) return null;
|
||||
return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
|
||||
}
|
||||
|
||||
/** Background colour a node itself paints, or null when unknown / not a flat colour. */
|
||||
function ownBackground(props: Record<string, any>, design: SiteDesign): RGB | null | 'none' {
|
||||
// Sections with a gradient/image background: contrast is not computable.
|
||||
if (props.bgType && props.bgType !== 'color' && props.bgType !== 'solid') return null;
|
||||
if (props.bgImage) return null;
|
||||
const raw = props.style?.backgroundColor || props.bgColor;
|
||||
if (typeof raw !== 'string' || !raw.trim()) return 'none';
|
||||
const resolved = resolveTokenValue(raw, design);
|
||||
if (/gradient|url\(/i.test(resolved)) return null;
|
||||
const rgb = parseColor(resolved);
|
||||
return rgb ?? 'none'; // transparent -> keep looking upward
|
||||
}
|
||||
|
||||
function effectiveBackground(nodes: SNodes, id: string, design: SiteDesign): RGB | null {
|
||||
let cur = nodes[id]?.parent;
|
||||
while (cur && nodes[cur]) {
|
||||
const bg = ownBackground(nodes[cur].props ?? {}, design);
|
||||
if (bg === null) return null;
|
||||
if (bg !== 'none') return bg;
|
||||
cur = nodes[cur].parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const isEmptyHref = (h: unknown) => typeof h !== 'string' || h.trim() === '' || h.trim() === '#';
|
||||
const label = (s: unknown, fallback: string) => {
|
||||
const t = typeof s === 'string' ? s.trim().replace(/\s+/g, ' ') : '';
|
||||
return t ? `"${t.length > 30 ? t.slice(0, 29) + '…' : t}"` : fallback;
|
||||
};
|
||||
const plain = (s: unknown) => (typeof s === 'string' ? s.replace(/<[^>]*>/g, '') : '');
|
||||
|
||||
function linkChecks(nodes: SNodes, ids: string[], pageId: string, where: string, out: Check[]) {
|
||||
const add = (nodeId: string, suffix: string, text: unknown) =>
|
||||
out.push({
|
||||
id: `link:${pageId}:${nodeId}:${suffix}`,
|
||||
severity: 'warn',
|
||||
message: `${where}: ${label(text, 'a link')} has no destination (links to #)`,
|
||||
pageId,
|
||||
nodeId,
|
||||
fix: 'select',
|
||||
});
|
||||
for (const id of ids) {
|
||||
const n = nodes[id];
|
||||
const name = n.type?.resolvedName;
|
||||
const p = n.props ?? {};
|
||||
if (name === 'ButtonLink') {
|
||||
if (isEmptyHref(p.href)) add(id, 'btn', p.text);
|
||||
} else if (name === 'Navbar' || name === 'Menu') {
|
||||
(Array.isArray(p.links) ? p.links : []).forEach((l: any, i: number) => {
|
||||
if (l && isEmptyHref(l.href)) add(id, `l${i}`, l.text);
|
||||
});
|
||||
} else if (name === 'HeroSimple' || name === 'CTASection' || name === 'CallToAction') {
|
||||
normalizeCtas(p as any).forEach((c, i) => {
|
||||
if (isEmptyHref(c.href)) add(id, `c${i}`, c.text);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function h1Count(nodes: SNodes, ids: string[]): number {
|
||||
let n = 0;
|
||||
for (const id of ids) {
|
||||
const node = nodes[id];
|
||||
const name = node.type?.resolvedName;
|
||||
const p = node.props ?? {};
|
||||
if (name === 'Heading' && p.level === 'h1') n++;
|
||||
// HeroSimple exports its heading as an <h1>.
|
||||
else if (name === 'HeroSimple' && typeof p.heading === 'string' && p.heading.trim()) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Runs every pre-publish check; capped at {@link MAX_CHECKS}, deduped by id. */
|
||||
export function runPublishChecks(input: PublishChecksInput): Check[] {
|
||||
const { pages, design, zones = [] } = input;
|
||||
const out: Check[] = [];
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
const nodes = parseState(page.craftState);
|
||||
const ids = nodes ? walk(nodes) : [];
|
||||
const where = page.name || page.slug;
|
||||
const seo = page.seo ?? {};
|
||||
|
||||
if (!nodes || (nodes.ROOT?.nodes ?? []).length === 0) {
|
||||
out.push({ id: `empty:${page.id}`, severity: 'warn', message: `${where} is empty`, pageId: page.id });
|
||||
}
|
||||
|
||||
const title = seo.metaTitle || page.name || '';
|
||||
if (title.length > TITLE_MAX) {
|
||||
out.push({
|
||||
id: `title-long:${page.id}`, severity: 'warn',
|
||||
message: `${where}: page title is ${title.length} characters (search engines cut it off after ${TITLE_MAX})`,
|
||||
pageId: page.id, fix: 'page-settings',
|
||||
});
|
||||
}
|
||||
if (!seo.metaDescription || !seo.metaDescription.trim()) {
|
||||
out.push({
|
||||
id: `meta-desc:${page.id}`, severity: 'warn',
|
||||
message: `${where} has no meta description`,
|
||||
pageId: page.id, fix: 'page-settings',
|
||||
});
|
||||
}
|
||||
if (index === 0 && seo.noindex) {
|
||||
out.push({
|
||||
id: `noindex-landing:${page.id}`, severity: 'info',
|
||||
message: `${where} is the landing page and is set to noindex: search engines will skip it`,
|
||||
pageId: page.id, fix: 'page-settings',
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodes) return;
|
||||
|
||||
const h1s = h1Count(nodes, ids);
|
||||
if (h1s === 0 && ids.length > 1) {
|
||||
out.push({ id: `h1-none:${page.id}`, severity: 'warn', message: `No H1 heading on ${where}`, pageId: page.id });
|
||||
} else if (h1s > 1) {
|
||||
out.push({ id: `h1-multi:${page.id}`, severity: 'warn', message: `${where} has ${h1s} H1 headings (use one)`, pageId: page.id });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
const n = nodes[id];
|
||||
const name = n.type?.resolvedName;
|
||||
const p = n.props ?? {};
|
||||
if (name === 'ImageBlock') {
|
||||
const src = typeof p.src === 'string' ? p.src.trim() : '';
|
||||
if (src && src !== PLACEHOLDER_SRC && !(typeof p.alt === 'string' && p.alt.trim())) {
|
||||
out.push({
|
||||
id: `alt:${page.id}:${id}`, severity: 'warn',
|
||||
message: `${where}: an image is missing alt text`,
|
||||
pageId: page.id, nodeId: id, fix: 'select',
|
||||
});
|
||||
}
|
||||
} else if (name === 'Heading' || name === 'TextBlock') {
|
||||
const colorRaw = p.style?.color;
|
||||
if (typeof colorRaw !== 'string' || !colorRaw) continue;
|
||||
const fg = parseColor(resolveTokenValue(colorRaw, design));
|
||||
const bg = fg ? effectiveBackground(nodes, id, design) : null;
|
||||
if (!fg || !bg) continue;
|
||||
const size = pxSize(p.style?.fontSize);
|
||||
const large = size !== null && size >= 24;
|
||||
const ratio = contrastRatio(fg, bg);
|
||||
const txt = label(plain(p.text ?? p.html), '');
|
||||
if (ratio < (large ? 3 : 4.5)) {
|
||||
out.push({
|
||||
id: `contrast:${page.id}:${id}`, severity: 'warn',
|
||||
message: `${where}: low-contrast ${name === 'Heading' ? 'heading' : 'text'}${txt ? ' ' + txt : ''} (${ratio.toFixed(1)}:1, needs ${large ? 3 : 4.5}:1)`,
|
||||
pageId: page.id, nodeId: id, fix: 'select',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
linkChecks(nodes, ids, page.id, where, out);
|
||||
});
|
||||
|
||||
for (const zone of zones) {
|
||||
const nodes = parseState(zone.craftState);
|
||||
if (!nodes) continue;
|
||||
linkChecks(nodes, walk(nodes), zone.id, zone.name, out);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: Check[] = [];
|
||||
for (const c of out) {
|
||||
if (seen.has(c.id)) continue;
|
||||
seen.add(c.id);
|
||||
unique.push(c);
|
||||
if (unique.length >= MAX_CHECKS) break;
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
Reference in new issue
Block a user