From c0fd183c116458a735dd53f8bfdbaf1e6dcd5eee Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:05:28 -0700 Subject: [PATCH] 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 -> after the favicon line in both Preview and export. Refs #54 --- craft/src/editor/LoadGate.conflict.test.tsx | 9 +- craft/src/editor/SaveSerialise.test.tsx | 9 +- craft/src/editor/SaveState.test.tsx | 13 +- .../topbar/PublishChecklistModal.test.tsx | 107 ++++++ .../panels/topbar/PublishChecklistModal.tsx | 89 +++++ craft/src/panels/topbar/TopBar.test.tsx | 103 +++++- craft/src/panels/topbar/TopBar.tsx | 65 +++- craft/src/styles/editor.css | 11 + craft/src/utils/html-export.test.ts | 35 ++ craft/src/utils/html-export.ts | 11 +- craft/src/utils/publish-checks.test.ts | 168 ++++++++++ craft/src/utils/publish-checks.ts | 306 ++++++++++++++++++ 12 files changed, 913 insertions(+), 13 deletions(-) create mode 100644 craft/src/panels/topbar/PublishChecklistModal.test.tsx create mode 100644 craft/src/panels/topbar/PublishChecklistModal.tsx create mode 100644 craft/src/utils/publish-checks.test.ts create mode 100644 craft/src/utils/publish-checks.ts diff --git a/craft/src/editor/LoadGate.conflict.test.tsx b/craft/src/editor/LoadGate.conflict.test.tsx index 349084f..d1405c5 100644 --- a/craft/src/editor/LoadGate.conflict.test.tsx +++ b/craft/src/editor/LoadGate.conflict.test.tsx @@ -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(); }); +} diff --git a/craft/src/editor/SaveSerialise.test.tsx b/craft/src/editor/SaveSerialise.test.tsx index 5fe67d6..a61f872 100644 --- a/craft/src/editor/SaveSerialise.test.tsx +++ b/craft/src/editor/SaveSerialise.test.tsx @@ -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(); }); +} diff --git a/craft/src/editor/SaveState.test.tsx b/craft/src/editor/SaveState.test.tsx index a7e22e8..e2a1c8f 100644 --- a/craft/src/editor/SaveState.test.tsx +++ b/craft/src/editor/SaveState.test.tsx @@ -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(); }); +} diff --git a/craft/src/panels/topbar/PublishChecklistModal.test.tsx b/craft/src/panels/topbar/PublishChecklistModal.test.tsx new file mode 100644 index 0000000..04b5e95 --- /dev/null +++ b/craft/src/panels/topbar/PublishChecklistModal.test.tsx @@ -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 / + 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) => 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 ( + <> + {activePageId} + + + ); +}; + +const tick = async () => { await h!.act(async () => { await new Promise((r) => setTimeout(r, 30)); }); }; +const q = (id: string, i = 0) => h!.container.ownerDocument.querySelectorAll(`[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(); + 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('[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('[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( + + {}} onCancel={() => {}} /> + , + ); + expect(h.container.ownerDocument.querySelectorAll('[data-testid="publish-check-fix"]')).toHaveLength(0); + expect(h.container.ownerDocument.body.textContent).toContain('X is empty'); + }); +}); diff --git a/craft/src/panels/topbar/PublishChecklistModal.tsx b/craft/src/panels/topbar/PublishChecklistModal.tsx new file mode 100644 index 0000000..cd0024e --- /dev/null +++ b/craft/src/panels/topbar/PublishChecklistModal.tsx @@ -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 = ({ checks, showGeneratedNote, onConfirm, onCancel }) => { + const { actions } = useEditor(); + const { pages, headerPage, footerPage, activePageId, switchPage } = usePages(); + const timer = useRef | 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 ( +
+
+

Before you publish

+

{checks.length === 1 ? '1 thing' : `${checks.length} things`} worth a look. None of them stop you from publishing.

+
+ {groups.map((g) => ( +
+

{nameOf(g.pageId)}

+
    + {g.items.map((c) => ( +
  • + {c.message} + {canFix(c) && ( + + )} +
  • + ))} +
+
+ ))} +
+ {showGeneratedNote && ( +

sitemap.xml and robots.txt will be generated.

+ )} +
+ + +
+
+
+ ); +}; diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index 475c4cc..bebd63b 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -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) => { 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) => 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(`[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.'); + }); +}); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index 6a6e9a3..783b55a 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -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 = ({ showGuides, onToggleGuides }) => const [errorNotice, setErrorNotice] = useState(null); const [publishWarnings, setPublishWarnings] = useState([]); const [isDraft, setIsDraft] = useState(false); + // Pre-publish checklist (#54): non-null while the modal is up. + const [pendingChecks, setPendingChecks] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ showGuides, onToggleGuides }) => /> ) : null; + const checklistModal = pendingChecks ? ( + { void handlePublish({ skipChecks: true }); }} + onCancel={() => setPendingChecks(null)} + /> + ) : null; + const emptyBanner = emptyRefusal ? ( { void handleSave({ forceEmpty: true }); }} @@ -271,6 +318,10 @@ export const TopBar: React.FC = ({ 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 = ({ 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 = ({ showGuides, onToggleGuides }) => setPublishWarnings([])} /> {emptyBanner} {conflictModal} + {checklistModal} setErrorNotice(null)} />
{isWHP && ( @@ -369,7 +421,7 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => {isWHP && (