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:
shadowdao committed 2026-10-07 12:05:28 -07:00
1 parent 5430f13877
commit c0fd183c11
12 files changed
+913 -13

No files matched your search

+8 -1
View File
@@ -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(); });
}
+8 -1
View File
@@ -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(); });
}
+10 -3
View File
@@ -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>
);
};
+102 -1
View File
@@ -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.');
});
});
+59 -6
View File
@@ -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"
+11
View File
@@ -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; }
+35
View File
@@ -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&quot;b&lt;c&gt;&amp;d">');
expect(buildSeoMeta({ canonicalUrl: 'javascript:alert(1)' }, 'P')).not.toContain('canonical');
expect(buildSeoMeta({ canonicalUrl: '/relative' }, 'P')).not.toContain('canonical');
});
});
+10 -1
View File
@@ -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 ');
}
+168
View File
@@ -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);
});
});
+306
View File
@@ -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;
}