Compare commits
4
Commits
f7da654c11
...
enh-pages
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85dfe181aa | ||
|
|
a698f014b0 | ||
|
|
204ea5e078 | ||
|
|
0291ddce9a |
@@ -1,29 +1,95 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
import type { NodeTree } from '@craftjs/core';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
|
||||
return {
|
||||
rootNodeId: rootId,
|
||||
nodes: {
|
||||
[rootId]: {
|
||||
id: rootId,
|
||||
data: {
|
||||
type: { resolvedName: 'Container' },
|
||||
name: 'Container',
|
||||
displayName: 'Container',
|
||||
props,
|
||||
custom: {},
|
||||
isCanvas: false,
|
||||
parent: 'wherever-it-originally-lived',
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
hidden: false,
|
||||
},
|
||||
info: {},
|
||||
events: { selected: false, dragged: false, hovered: false },
|
||||
dom: null,
|
||||
related: {},
|
||||
rules: {},
|
||||
_hydrationTimestamp: 0,
|
||||
} as unknown as NodeTree['nodes'][string],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('clipboard', () => {
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
});
|
||||
|
||||
test('starts empty', () => {
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
});
|
||||
|
||||
test('set then get returns the stored node id', () => {
|
||||
setClipboardNodeId('node-123');
|
||||
expect(getClipboardNodeId()).toBe('node-123');
|
||||
test('set then get returns a tree with the same root id and shape', () => {
|
||||
const tree = makeTree('node-123', { text: 'hello' });
|
||||
setClipboardTree(tree);
|
||||
const got = getClipboardTree();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.rootNodeId).toBe('node-123');
|
||||
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
|
||||
});
|
||||
|
||||
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||
setClipboardNodeId('first');
|
||||
setClipboardNodeId('second');
|
||||
expect(getClipboardNodeId()).toBe('second');
|
||||
setClipboardTree(makeTree('first'));
|
||||
setClipboardTree(makeTree('second'));
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('second');
|
||||
});
|
||||
|
||||
test('can be cleared back to null', () => {
|
||||
setClipboardNodeId('node-123');
|
||||
setClipboardNodeId(null);
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
setClipboardTree(makeTree('node-123'));
|
||||
setClipboardTree(null);
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
});
|
||||
|
||||
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
|
||||
const original = makeTree('node-123', { text: 'original' });
|
||||
setClipboardTree(original);
|
||||
|
||||
// Mutate the original tree's props object directly (as if the source
|
||||
// node were edited, or the same live node got copied again).
|
||||
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
|
||||
|
||||
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
|
||||
});
|
||||
|
||||
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
|
||||
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
|
||||
setClipboardTree(original);
|
||||
|
||||
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
|
||||
|
||||
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
|
||||
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
|
||||
setClipboardTree(tree);
|
||||
tree = null; // simulate the original page's node/tree going away entirely
|
||||
|
||||
const got = getClipboardTree();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Node, NodeId, NodeTree } from '@craftjs/core';
|
||||
|
||||
/**
|
||||
* Tiny shared clipboard for canvas node copy/paste.
|
||||
*
|
||||
@@ -9,15 +11,71 @@
|
||||
* Deliberately not React state -- nothing in the UI needs to re-render
|
||||
* reactively when the clipboard changes; consumers just read the current
|
||||
* value at the moment they need it (on paste, or when a menu opens).
|
||||
*
|
||||
* Historical bug (cross-page copy/paste): this used to store only the copied
|
||||
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
|
||||
* at paste time. That works fine same-page, but the moment the user switches
|
||||
* pages the canvas is re-deserialized to the target page's Craft.js state --
|
||||
* the copied id no longer exists in `query` at all -- so a cross-page paste
|
||||
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
|
||||
* TREE SNAPSHOT at copy time instead means paste never needs to look the
|
||||
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
|
||||
* `actions.addNodeTree`, which works identically regardless of which page's
|
||||
* state is currently loaded on the canvas.
|
||||
*/
|
||||
let clipboardNodeId: string | null = null;
|
||||
let clipboardTree: NodeTree | null = null;
|
||||
|
||||
/** Returns the id of the node currently on the clipboard, or null if empty. */
|
||||
export function getClipboardNodeId(): string | null {
|
||||
return clipboardNodeId;
|
||||
/**
|
||||
* Deep, detached clone of a live Craft.js `NodeTree` (as returned by
|
||||
* `query.node(id).toNodeTree()`).
|
||||
*
|
||||
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
|
||||
* `data.type` is the actual component function/class reference (not a
|
||||
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
|
||||
* function and throws `DataCloneError` (see the identical note on
|
||||
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
|
||||
* historically). `type` is a stable reference shared by every node of that
|
||||
* component across the whole app (it doesn't change per page), so it's safe
|
||||
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
|
||||
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
|
||||
* subsequent paste's `setProp`, or a fresh copy of the same live node)
|
||||
* can never reach back into this stored snapshot.
|
||||
*/
|
||||
function cloneNodeTree(tree: NodeTree): NodeTree {
|
||||
const nodes: Record<NodeId, Node> = {};
|
||||
for (const [id, node] of Object.entries(tree.nodes)) {
|
||||
nodes[id] = {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
props: structuredClone(node.data.props),
|
||||
custom: structuredClone(node.data.custom),
|
||||
nodes: [...(node.data.nodes || [])],
|
||||
linkedNodes: { ...(node.data.linkedNodes || {}) },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { rootNodeId: tree.rootNodeId, nodes };
|
||||
}
|
||||
|
||||
/** Sets (or clears, with `null`) the node id on the clipboard. */
|
||||
export function setClipboardNodeId(nodeId: string | null): void {
|
||||
clipboardNodeId = nodeId;
|
||||
/**
|
||||
* Returns the tree snapshot currently on the clipboard, or null if empty.
|
||||
* The returned tree is safe to hand straight to `regenerateTreeIds` --
|
||||
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
|
||||
* same clipboard contents (including across a page switch) all work off the
|
||||
* same untouched snapshot.
|
||||
*/
|
||||
export function getClipboardTree(): NodeTree | null {
|
||||
return clipboardTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
|
||||
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
|
||||
* detached from the live Craft.js node it was captured from -- it survives
|
||||
* that node being deleted, mutated, or (the whole point) the canvas being
|
||||
* re-deserialized to a different page entirely.
|
||||
*/
|
||||
export function setClipboardTree(tree: NodeTree | null): void {
|
||||
clipboardTree = tree ? cloneNodeTree(tree) : null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import type { NodeTree, Node } from '@craftjs/core';
|
||||
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
/**
|
||||
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||
* input-focus guard.
|
||||
*
|
||||
* Also covers the cross-page clipboard fix: copy stores a detached TREE
|
||||
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
|
||||
* to re-resolve the original node via `query.node(id)`, which is exactly
|
||||
* what breaks once the canvas has been re-deserialized to a different page
|
||||
* (see `hooks/clipboard.ts` and the cross-page integration test in
|
||||
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
|
||||
*
|
||||
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||
* mounted via a bare consumer component, with REAL `keydown` events
|
||||
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
*/
|
||||
|
||||
const addNodeTreeMock = vi.fn();
|
||||
const selectNodeMock = vi.fn();
|
||||
let selectedIds: string[] = [];
|
||||
|
||||
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
|
||||
},
|
||||
};
|
||||
|
||||
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
||||
const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
|
||||
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||
ROOT: { data: { parent: null } },
|
||||
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
|
||||
ROOT: { data: { parent: null, nodes: [] } },
|
||||
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||
};
|
||||
|
||||
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
|
||||
},
|
||||
actions: {
|
||||
addNodeTree: addNodeTreeMock,
|
||||
selectNode: selectNodeMock,
|
||||
history: { undo: vi.fn(), redo: vi.fn() },
|
||||
delete: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
|
||||
beforeEach(() => {
|
||||
selectedIds = [];
|
||||
addNodeTreeMock.mockClear();
|
||||
selectNodeMock.mockClear();
|
||||
regenerateTreeIdsMock.mockClear();
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
if (root) unmount();
|
||||
});
|
||||
|
||||
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
||||
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
|
||||
render(<Consumer />);
|
||||
selectedIds = ['selected-1'];
|
||||
selectedIds = ['copied-root-1'];
|
||||
|
||||
pressKey('c');
|
||||
|
||||
expect(getClipboardNodeId()).toBe('selected-1');
|
||||
const clip = getClipboardTree();
|
||||
expect(clip).not.toBeNull();
|
||||
expect(clip!.rootNodeId).toBe('copied-root-1');
|
||||
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
|
||||
});
|
||||
|
||||
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
||||
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBe('copied-root-1');
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
|
||||
|
||||
selectedIds = ['selected-1'];
|
||||
pressKey('v');
|
||||
|
||||
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
|
||||
// Sibling of the current selection: selected-1's data.parent.
|
||||
expect(targetParent).toBe('parent-container-1');
|
||||
// Immediately after selected-1 (index 1 among parent-container-1's
|
||||
// children), matching duplicate()'s "insert right after" UX.
|
||||
expect(insertIndex).toBe(2);
|
||||
|
||||
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||
// ids the copied node already occupies in the live Craft.js tree.
|
||||
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
for (const id of pastedIds) {
|
||||
expect(originalIds.has(id)).toBe(false);
|
||||
}
|
||||
|
||||
// The new copy is selected, same as duplicate()'s existing UX.
|
||||
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
|
||||
});
|
||||
|
||||
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
||||
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
pressKey('v');
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
expect(targetParent).toBe('ROOT');
|
||||
expect(insertIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
|
||||
render(<Consumer />);
|
||||
|
||||
selectedIds = ['copied-root-1'];
|
||||
pressKey('c');
|
||||
|
||||
selectedIds = [];
|
||||
pressKey('v');
|
||||
|
||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||
expect(targetParent).toBe('ROOT');
|
||||
expect(insertIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
selectedIds = ['selected-1'];
|
||||
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
|
||||
setClipboardNodeId('copied-root-1');
|
||||
setClipboardTree(COPIED_TREE);
|
||||
pressKey('v');
|
||||
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
selectedIds = ['selected-1'];
|
||||
|
||||
pressKey('c');
|
||||
expect(getClipboardNodeId()).toBeNull();
|
||||
expect(getClipboardTree()).toBeNull();
|
||||
|
||||
activeElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C: copy selected node id to the shared clipboard
|
||||
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
|
||||
// just its id -- see clipboard.ts for why: an id-based clipboard can't
|
||||
// survive a page switch, since the copied id no longer exists in
|
||||
// `query` once the canvas is re-deserialized to a different page).
|
||||
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||
setClipboardNodeId(selected[0]);
|
||||
setClipboardTree(query.node(selected[0]).toNodeTree());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste the clipboard node as a sibling of the current selection
|
||||
// Ctrl+V: paste the clipboard tree as a sibling of the current
|
||||
// selection (immediately after it, matching duplicate()'s UX), or
|
||||
// append to ROOT when nothing is selected. Works regardless of which
|
||||
// page is currently on the canvas -- the clipboard tree is a detached
|
||||
// snapshot, not a reference to a node that may no longer exist here.
|
||||
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const sourceId = getClipboardNodeId();
|
||||
if (!sourceId || !query.node(sourceId).get()) return;
|
||||
const clip = getClipboardTree();
|
||||
if (!clip) return;
|
||||
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length === 0) return;
|
||||
const selectedId = selected[0];
|
||||
const selectedId = selected.length > 0 ? selected[0] : null;
|
||||
|
||||
let targetParent = 'ROOT';
|
||||
if (selectedId !== 'ROOT') {
|
||||
let targetParentId = 'ROOT';
|
||||
let insertIndex: number | undefined;
|
||||
if (selectedId && selectedId !== 'ROOT') {
|
||||
const node = query.node(selectedId).get();
|
||||
targetParent = node?.data?.parent || 'ROOT';
|
||||
const parentId: string | null | undefined = node?.data?.parent;
|
||||
if (parentId) {
|
||||
targetParentId = parentId;
|
||||
try {
|
||||
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||
const idx = siblings.indexOf(selectedId);
|
||||
if (idx !== -1) insertIndex = idx + 1;
|
||||
} catch {
|
||||
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
||||
actions.addNodeTree(tree, targetParent);
|
||||
const tree = regenerateTreeIds(clip);
|
||||
if (insertIndex !== undefined) {
|
||||
actions.addNodeTree(tree, targetParentId, insertIndex);
|
||||
} else {
|
||||
actions.addNodeTree(tree, targetParentId);
|
||||
}
|
||||
actions.selectNode(tree.rootNodeId);
|
||||
} catch (err) {
|
||||
console.error('Paste failed:', err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { PageProvider, usePages } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { useWhpApi } from './useWhpApi';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
/**
|
||||
* PKG-H §5 round-trip coverage: `load()` must restore per-page `seo`
|
||||
* (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed
|
||||
* `PageData`, exactly like it already restores `craftState`. Mocks
|
||||
* `@craftjs/core`'s `useEditor` (same pattern as
|
||||
* `PageContext.pure-updaters.test.tsx`) since this test only needs
|
||||
* `query.serialize`/`actions.deserialize` as inert stubs -- it drives
|
||||
* `load()`, not the live canvas.
|
||||
*/
|
||||
const deserializeMock = vi.fn();
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => '{}' },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'testuser',
|
||||
apiUrl: '/panel/api/site-builder',
|
||||
csrfToken: 'tok',
|
||||
siteId: 42,
|
||||
siteDomain: 'example.com',
|
||||
siteName: 'Test Site',
|
||||
backUrl: '/panel/sites',
|
||||
isRoot: false,
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
interface Captured {
|
||||
load: ReturnType<typeof useWhpApi>['load'];
|
||||
pages: ReturnType<typeof usePages>['pages'];
|
||||
}
|
||||
|
||||
function render(): { get: () => Captured } {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let captured: Captured | null = null;
|
||||
|
||||
const Consumer: React.FC = () => {
|
||||
const { load } = useWhpApi();
|
||||
const { pages } = usePages();
|
||||
captured = { load, pages };
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return { get: () => captured! };
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
|
||||
beforeEach(() => {
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => {
|
||||
const seoPayload = {
|
||||
metaTitle: 'Custom Title',
|
||||
metaDescription: 'A custom description.',
|
||||
ogTitle: 'Custom OG Title',
|
||||
ogImage: '/uploads/og.jpg',
|
||||
twitterCard: 'summary_large_image' as const,
|
||||
noindex: true,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
design: null,
|
||||
header_craft_state: null,
|
||||
footer_craft_state: null,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload },
|
||||
{ id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const harness = render();
|
||||
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
});
|
||||
|
||||
const { pages } = harness.get();
|
||||
expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload);
|
||||
// A page with no seo in the payload stays undefined -- back-compat, not
|
||||
// coerced into an empty object.
|
||||
expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
design: null,
|
||||
header_craft_state: null,
|
||||
footer_craft_state: null,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const harness = render();
|
||||
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
});
|
||||
|
||||
const { pages } = harness.get();
|
||||
expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -147,4 +147,63 @@ describe('buildSavePayload', () => {
|
||||
expect(payload.design).toEqual(design);
|
||||
expect(payload.design.headCode).toBe('<meta name="x">');
|
||||
});
|
||||
|
||||
test('PKG-H §5: per-page seo is included in both pages_craft_state and pages entries', () => {
|
||||
const pageWithSeo: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
slug: 'index',
|
||||
craftState: 'STORED_HOME',
|
||||
seo: {
|
||||
metaTitle: 'Custom Title',
|
||||
metaDescription: 'Custom description',
|
||||
ogTitle: 'Custom OG',
|
||||
ogImage: '/uploads/og.jpg',
|
||||
twitterCard: 'summary_large_image',
|
||||
noindex: true,
|
||||
},
|
||||
};
|
||||
|
||||
const payload = buildSavePayload({
|
||||
siteId: 1,
|
||||
siteName: 'Test Site',
|
||||
liveCraftState: 'LIVE_PAGE_HOME',
|
||||
pages: [pageWithSeo, pageB],
|
||||
headerPage,
|
||||
footerPage,
|
||||
activePageId: 'home',
|
||||
isEditingHeader: false,
|
||||
isEditingFooter: false,
|
||||
headCode: DEFAULT_SITE_DESIGN.headCode,
|
||||
design: DEFAULT_SITE_DESIGN,
|
||||
});
|
||||
|
||||
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.seo).toEqual(pageWithSeo.seo);
|
||||
expect(payload.pages.find((p) => p.filename === 'index.html')?.seo).toEqual(pageWithSeo.seo);
|
||||
|
||||
// A page with no seo overrides omits the field entirely (undefined),
|
||||
// not an empty object -- back-compat with pre-PKG-H saved shapes.
|
||||
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
|
||||
expect(payload.pages.find((p) => p.filename === 'about.html')?.seo).toBeUndefined();
|
||||
});
|
||||
|
||||
test('PKG-H §5: favicon flows through the design object already carried by the payload', () => {
|
||||
const design = { ...DEFAULT_SITE_DESIGN, favicon: '/uploads/favicon.png' };
|
||||
|
||||
const payload = buildSavePayload({
|
||||
siteId: 1,
|
||||
siteName: 'Test Site',
|
||||
liveCraftState: 'LIVE_PAGE_HOME',
|
||||
pages: [pageA, pageB],
|
||||
headerPage,
|
||||
footerPage,
|
||||
activePageId: 'home',
|
||||
isEditingHeader: false,
|
||||
isEditingFooter: false,
|
||||
headCode: DEFAULT_SITE_DESIGN.headCode,
|
||||
design,
|
||||
});
|
||||
|
||||
expect(payload.design.favicon).toBe('/uploads/favicon.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,12 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
filename,
|
||||
title: page.name,
|
||||
html: pageHtml,
|
||||
// PKG-H §5: per-page SEO overrides, read by the backend's
|
||||
// `handlePublish` (mirrors the existing `title` field above) to build
|
||||
// the published <head> for this file. Omitted (undefined) when the
|
||||
// page has no seo overrides -- JSON.stringify drops undefined keys,
|
||||
// so legacy-shaped payloads round-trip unchanged.
|
||||
seo: page.seo,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -166,6 +172,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
// name.html) lines up with the file we just wrote (index.html).
|
||||
slug: i === 0 ? 'index' : page.slug,
|
||||
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
||||
// PKG-H §5: round-trips PageData.seo through save -> load() so the
|
||||
// editor's SEO fields survive a reload (mirrors craftState above).
|
||||
seo: page.seo,
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -292,8 +301,8 @@ export function useWhpApi() {
|
||||
|
||||
// Restore pages and load the first page into the canvas
|
||||
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null,
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||
})));
|
||||
|
||||
// Load the first page (home) into the canvas
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||
|
||||
interface ContextMenuProps {
|
||||
@@ -79,35 +79,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
const copyNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
setClipboardNodeId(nodeId);
|
||||
// Store a detached subtree snapshot, not just the id -- an id-based
|
||||
// clipboard can't survive a page switch (the copied id no longer
|
||||
// exists in `query` once the canvas is re-deserialized to a different
|
||||
// page's Craft.js state). See clipboard.ts.
|
||||
setClipboardTree(query.node(nodeId).toNodeTree());
|
||||
} catch (e) {
|
||||
console.error('Copy failed:', e);
|
||||
}
|
||||
onClose();
|
||||
}, [nodeId, onClose]);
|
||||
}, [nodeId, query, onClose]);
|
||||
|
||||
const pasteNode = useCallback(() => {
|
||||
const sourceId = getClipboardNodeId();
|
||||
if (!sourceId) {
|
||||
const clip = getClipboardTree();
|
||||
if (!clip) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!query.node(sourceId).get()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste as a SIBLING of the right-clicked node, not as its child --
|
||||
// using the clicked node itself as the parent throws when it's a leaf.
|
||||
// Paste as a SIBLING of the right-clicked node (immediately after it,
|
||||
// matching duplicate()'s UX), not as its child -- using the clicked
|
||||
// node itself as the parent throws when it's a leaf. Falls back to
|
||||
// appending into ROOT when nothing valid was right-clicked. This works
|
||||
// regardless of which page is on the canvas -- the clipboard tree is a
|
||||
// detached snapshot, not a reference to a node that may not exist here.
|
||||
let targetParent = 'ROOT';
|
||||
let insertIndex: number | undefined;
|
||||
if (nodeId && nodeId !== 'ROOT') {
|
||||
const clickedNode = query.node(nodeId).get();
|
||||
targetParent = clickedNode?.data?.parent || 'ROOT';
|
||||
const parentId: string | null | undefined = clickedNode?.data?.parent;
|
||||
if (parentId) {
|
||||
targetParent = parentId;
|
||||
try {
|
||||
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||
const idx = siblings.indexOf(nodeId);
|
||||
if (idx !== -1) insertIndex = idx + 1;
|
||||
} catch {
|
||||
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
||||
const tree = regenerateTreeIds(clip);
|
||||
if (insertIndex !== undefined) {
|
||||
actions.addNodeTree(tree, targetParent, insertIndex);
|
||||
} else {
|
||||
actions.addNodeTree(tree, targetParent);
|
||||
}
|
||||
actions.selectNode(tree.rootNodeId);
|
||||
} catch (e) {
|
||||
console.error('Paste failed:', e);
|
||||
}
|
||||
@@ -176,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
icon: 'clipboard',
|
||||
shortcut: 'Ctrl+V',
|
||||
action: pasteNode,
|
||||
disabled: !getClipboardNodeId(),
|
||||
disabled: !getClipboardTree(),
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { AssetPicker } from '../../ui/AssetPicker';
|
||||
import { PageData, PageSeo } from '../../types';
|
||||
|
||||
interface PageSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
page: PageData | null;
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SEO: PageSeo = {
|
||||
metaTitle: '',
|
||||
metaDescription: '',
|
||||
ogTitle: '',
|
||||
ogImage: '',
|
||||
twitterCard: 'summary_large_image',
|
||||
noindex: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-page SEO/meta settings (PKG-H contract §1/§4). Opened via the gear
|
||||
* icon on a page row in PagesPanel. Fields map 1:1 onto `PageData.seo`
|
||||
* (`types/index.ts`) and are consumed by `html-export.ts`'s `wrapInDocument`
|
||||
* (Preview) and the backend's `generateCompiledHTML` (published output) --
|
||||
* field names here MUST match the shared contract exactly.
|
||||
*
|
||||
* Follows the HeadCodeModal precedent: portaled to `document.body` (escapes
|
||||
* the topbar/left-panel's own stacking context, same fix HeadCodeModal and
|
||||
* TemplateModal already needed), built on the shared `Modal` shell, edits
|
||||
* commit immediately via `updatePageSeo` (no separate Save step) rather than
|
||||
* buffering to a "Save" button -- consistent with HeadCodeModal's live-edit
|
||||
* pattern for `design.headCode`.
|
||||
*/
|
||||
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo }) => {
|
||||
const [seo, setSeo] = useState<PageSeo>(EMPTY_SEO);
|
||||
|
||||
// Re-sync local form state from the target page's stored seo whenever the
|
||||
// modal opens (or the target page changes while open) -- mirrors the
|
||||
// pattern of resetting form-local state on prop identity change rather
|
||||
// than deriving directly from props (dropdowns/checkboxes need editable
|
||||
// local state).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) });
|
||||
}, [open, page?.id, page?.seo]);
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
const update = (patch: Partial<PageSeo>) => {
|
||||
setSeo((prev) => ({ ...prev, ...patch }));
|
||||
updatePageSeo(page.id, patch);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<i className="fa fa-cog" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Page Settings — {page.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
SEO and social-sharing metadata for this page only.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle} aria-label="Close">
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 20, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Meta Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="control-input"
|
||||
value={seo.metaTitle || ''}
|
||||
onChange={(e) => update({ metaTitle: e.target.value })}
|
||||
placeholder={page.name}
|
||||
data-testid="page-seo-meta-title"
|
||||
/>
|
||||
<p style={hintStyle}>Overrides the browser tab title. Leave blank to use the page name.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Meta Description</label>
|
||||
<textarea
|
||||
className="control-input"
|
||||
value={seo.metaDescription || ''}
|
||||
onChange={(e) => update({ metaDescription: e.target.value })}
|
||||
placeholder="A short summary shown in search results and link previews..."
|
||||
rows={3}
|
||||
style={{ resize: 'vertical' as const }}
|
||||
data-testid="page-seo-meta-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Social Share Title (og:title)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="control-input"
|
||||
value={seo.ogTitle || ''}
|
||||
onChange={(e) => update({ ogTitle: e.target.value })}
|
||||
placeholder={seo.metaTitle || page.name}
|
||||
data-testid="page-seo-og-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Social Share Image (og:image)</label>
|
||||
<AssetPicker
|
||||
value={seo.ogImage || ''}
|
||||
onChange={(url) => update({ ogImage: url })}
|
||||
mediaType="image"
|
||||
variant="full"
|
||||
placeholder="Or paste image URL..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Twitter Card Type</label>
|
||||
<select
|
||||
className="control-select"
|
||||
value={seo.twitterCard || 'summary_large_image'}
|
||||
onChange={(e) => update({ twitterCard: e.target.value as PageSeo['twitterCard'] })}
|
||||
data-testid="page-seo-twitter-card"
|
||||
>
|
||||
<option value="summary">Summary</option>
|
||||
<option value="summary_large_image">Summary with Large Image</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!seo.noindex}
|
||||
onChange={(e) => update({ noindex: e.target.checked })}
|
||||
data-testid="page-seo-noindex"
|
||||
/>
|
||||
Hide this page from search engines (noindex)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
}}>
|
||||
<button onClick={onClose} style={doneButtonStyle}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Styles ---------- */
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: '90vw',
|
||||
maxWidth: 560,
|
||||
maxHeight: '85vh',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
const modalHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const doneButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const fieldLabelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3px',
|
||||
};
|
||||
|
||||
const hintStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
color: 'var(--color-text-dim)',
|
||||
margin: '4px 0 0',
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
import { PageSettingsModal } from './PageSettingsModal';
|
||||
|
||||
export const PagesPanel: React.FC = () => {
|
||||
const {
|
||||
@@ -14,6 +15,10 @@ export const PagesPanel: React.FC = () => {
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
duplicatePage,
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
} = usePages();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -22,6 +27,8 @@ export const PagesPanel: React.FC = () => {
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editSlug, setEditSlug] = useState('');
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
|
||||
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newName.trim()) return;
|
||||
@@ -64,6 +71,33 @@ export const PagesPanel: React.FC = () => {
|
||||
* differently-colored category. The active/editing state reuses the same
|
||||
* accent-outline treatment the page list already uses for the active page,
|
||||
* so there's one consistent "this is what's currently open" affordance. */
|
||||
/* ---------- Per-page-row icon button ----------
|
||||
* Shared 24x24 icon-button styling used by every action in the page row
|
||||
* (SEO settings, rename, duplicate, reorder, set-as-home, delete) so a new
|
||||
* action slots in looking identical to the pre-existing gear/pencil/trash
|
||||
* buttons -- same dark-theme neutral treatment, `disabled` dims the icon
|
||||
* (matching how `disabled` already reads elsewhere in this panel, e.g. the
|
||||
* "Add Page" button), and `danger` reuses the existing delete-button
|
||||
* accent color. */
|
||||
const pageActionBtnStyle = (opts: { disabled?: boolean; danger?: boolean } = {}): React.CSSProperties => ({
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: opts.disabled
|
||||
? 'var(--color-text-dim)'
|
||||
: opts.danger
|
||||
? 'var(--color-danger)'
|
||||
: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: opts.disabled ? 'default' : 'pointer',
|
||||
opacity: opts.disabled ? 0.5 : 1,
|
||||
});
|
||||
|
||||
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -340,26 +374,66 @@ export const PagesPanel: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
|
||||
style={{ display: 'flex', gap: 2, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 96 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => movePage(page.id, 'up')}
|
||||
disabled={pageIndex === 0}
|
||||
data-tooltip="Move up"
|
||||
aria-label={`Move ${page.name} up`}
|
||||
style={pageActionBtnStyle({ disabled: pageIndex === 0 })}
|
||||
>
|
||||
<i className="fa fa-arrow-up" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => movePage(page.id, 'down')}
|
||||
disabled={pageIndex === pages.length - 1}
|
||||
data-tooltip="Move down"
|
||||
aria-label={`Move ${page.name} down`}
|
||||
style={pageActionBtnStyle({ disabled: pageIndex === pages.length - 1 })}
|
||||
>
|
||||
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => duplicatePage(page.id)}
|
||||
data-tooltip="Duplicate"
|
||||
aria-label={`Duplicate ${page.name}`}
|
||||
style={pageActionBtnStyle()}
|
||||
>
|
||||
<i className="fa fa-clone" aria-hidden="true" />
|
||||
</button>
|
||||
{isLanding ? (
|
||||
<span
|
||||
data-tooltip="This is the home page"
|
||||
aria-label={`${page.name} is the home page`}
|
||||
style={pageActionBtnStyle({ disabled: true })}
|
||||
>
|
||||
<i className="fa fa-home" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setLandingPage(page.id)}
|
||||
data-tooltip="Set as home page"
|
||||
aria-label={`Set ${page.name} as the home page`}
|
||||
style={pageActionBtnStyle()}
|
||||
>
|
||||
<i className="fa fa-home" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSeoSettingsPageId(page.id)}
|
||||
data-tooltip="Page Settings (SEO)"
|
||||
aria-label={`Page settings for ${page.name}`}
|
||||
style={pageActionBtnStyle()}
|
||||
>
|
||||
<i className="fa fa-cog" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startEditing(page)}
|
||||
data-tooltip="Rename"
|
||||
aria-label={`Rename ${page.name}`}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={pageActionBtnStyle()}
|
||||
>
|
||||
<i className="fa fa-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -368,19 +442,7 @@ export const PagesPanel: React.FC = () => {
|
||||
onClick={() => setDeleteConfirmId(page.id)}
|
||||
data-tooltip="Delete"
|
||||
aria-label={`Delete ${page.name}`}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={pageActionBtnStyle({ danger: true })}
|
||||
>
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -492,6 +554,13 @@ export const PagesPanel: React.FC = () => {
|
||||
+ Add Page
|
||||
</button>
|
||||
)}
|
||||
|
||||
<PageSettingsModal
|
||||
open={seoSettingsPageId !== null}
|
||||
onClose={() => setSeoSettingsPageId(null)}
|
||||
page={seoSettingsPage}
|
||||
updatePageSeo={updatePageSeo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { FONT_FAMILIES } from '../../constants/presets';
|
||||
import { AssetPicker } from '../../ui/AssetPicker';
|
||||
|
||||
type DesignTab = 'basic' | 'advanced';
|
||||
|
||||
@@ -200,6 +201,19 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
{/* Basic tab */}
|
||||
{tab === 'basic' && (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<label className="guided-section-label">Favicon</label>
|
||||
<AssetPicker
|
||||
value={design.favicon}
|
||||
onChange={(url) => updateDesign({ favicon: url })}
|
||||
mediaType="image"
|
||||
variant="full"
|
||||
placeholder="Or paste favicon URL..."
|
||||
/>
|
||||
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', margin: '4px 0 0' }}>
|
||||
Shown in browser tabs and bookmarks. Applies site-wide.
|
||||
</p>
|
||||
</div>
|
||||
<ColorField
|
||||
label="Primary Color"
|
||||
value={design.primaryColor}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish, load } = useWhpApi();
|
||||
const { headerPage, footerPage } = usePages();
|
||||
const { headerPage, footerPage, pages, activePageId } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
@@ -156,10 +156,27 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
|
||||
// Compose full page: header + body + footer
|
||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
||||
|
||||
// PKG-H: fold the active page's own SEO overrides + the site-wide
|
||||
// design tokens/favicon into the Preview export so editor Preview
|
||||
// === published output (contract §4/§7). `title` is the fully
|
||||
// resolved TITLE (seo.metaTitle || page.name) per the contract --
|
||||
// html-export's buildSeoMeta/og:title fallback both key off it.
|
||||
const activePage = pages.find((p) => p.id === activePageId);
|
||||
const seo = activePage?.seo;
|
||||
const title = seo?.metaTitle || activePage?.name || whpConfig?.siteName || 'Preview';
|
||||
|
||||
const result = exportToHtml(serialized, {
|
||||
title: whpConfig?.siteName || 'Preview',
|
||||
title,
|
||||
includeFonts: true,
|
||||
headCode: design.headCode,
|
||||
description: seo?.metaDescription,
|
||||
ogTitle: seo?.ogTitle,
|
||||
ogImage: seo?.ogImage,
|
||||
twitterCard: seo?.twitterCard,
|
||||
noindex: seo?.noindex,
|
||||
favicon: design.favicon,
|
||||
design,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version.
|
||||
@@ -183,7 +200,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
} catch (e) {
|
||||
console.error('Preview failed:', e);
|
||||
}
|
||||
}, [query, headerPage, footerPage, whpConfig, design]);
|
||||
}, [query, headerPage, footerPage, whpConfig, design, pages, activePageId]);
|
||||
|
||||
// Phase A: at ≤768px the topbar collapses to only the essentials --
|
||||
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageProvider, usePages, applyLandingInvariant } from './PageContext';
|
||||
import { PageData } from '../types';
|
||||
|
||||
/**
|
||||
* PKG-I: page duplicate / reorder / set-landing.
|
||||
*
|
||||
* The landing-page invariant is: `pages[0]` is the landing page, slug
|
||||
* LOCKED to `'index'`; every other page gets a real, unique slug. This
|
||||
* suite covers:
|
||||
* - `applyLandingInvariant` as a pure function (unit tests, no provider).
|
||||
* - `movePage`/`setLandingPage` re-establishing the invariant after
|
||||
* reordering, via `PageProvider`.
|
||||
* - `duplicatePage` inserting a copy right after the source with a copied
|
||||
* craftState + seo and a unique slug, and switching the canvas to it.
|
||||
*/
|
||||
|
||||
function makePage(overrides: Partial<PageData> & { id: string }): PageData {
|
||||
return { name: overrides.id, slug: overrides.id, craftState: null, ...overrides };
|
||||
}
|
||||
|
||||
describe('applyLandingInvariant (pure)', () => {
|
||||
test('page at index 0 gets slug "index" even if it held a different slug', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[0].slug).toBe('index');
|
||||
expect(result[0].id).toBe('a');
|
||||
});
|
||||
|
||||
test('demotes the old landing page (now at index > 0) to a unique real slug', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
const demoted = result.find((p) => p.id === 'b')!;
|
||||
expect(demoted.slug).not.toBe('index');
|
||||
expect(demoted.slug).toBe('home');
|
||||
});
|
||||
|
||||
test('never produces two pages with slug "index"', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
const indexSlugs = result.filter((p) => p.slug === 'index');
|
||||
expect(indexSlugs).toHaveLength(1);
|
||||
expect(indexSlugs[0].id).toBe('a');
|
||||
});
|
||||
|
||||
test('demoted page slug is deduped against a colliding existing slug elsewhere in the array', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'b', name: 'Home', slug: 'index' }), // demoted page, named "Home" -> slugifies to "home"
|
||||
makePage({ id: 'c', name: 'HomePage', slug: 'home' }), // unrelated page already using slug "home"
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[0].slug).toBe('index');
|
||||
const demoted = result.find((p) => p.id === 'b')!;
|
||||
expect(demoted.slug).toBe('home-2');
|
||||
const untouched = result.find((p) => p.id === 'c')!;
|
||||
expect(untouched.slug).toBe('home');
|
||||
|
||||
const slugs = result.map((p) => p.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
});
|
||||
|
||||
test('non-landing pages that already have a real slug are left untouched', () => {
|
||||
const pages = [
|
||||
makePage({ id: 'a', name: 'Home', slug: 'index' }),
|
||||
makePage({ id: 'b', name: 'About', slug: 'about' }),
|
||||
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||
];
|
||||
const result = applyLandingInvariant(pages);
|
||||
expect(result[1]).toEqual(pages[1]);
|
||||
expect(result[2]).toEqual(pages[2]);
|
||||
});
|
||||
|
||||
test('empty array is a no-op', () => {
|
||||
expect(applyLandingInvariant([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- PageProvider-mounted coverage ---------- */
|
||||
|
||||
let serializeReturn = '{}';
|
||||
const deserializeMock = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => serializeReturn },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
async function flushTimers() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
serializeReturn = '{}';
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
describe('PageContext.movePage', () => {
|
||||
test('moves a page up, swapping with its neighbor', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
// pages: [Home(index0), About, Contact]
|
||||
const contactId = ctx!.pages[2].id;
|
||||
|
||||
act(() => ctx!.movePage(contactId, 'up'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.name)).toEqual(['Home', 'Contact', 'About']);
|
||||
// Landing invariant still holds -- Home untouched at index 0.
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op at the top boundary', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const before = ctx!.pages.map((p) => p.id);
|
||||
|
||||
act(() => ctx!.movePage(homeId, 'up'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op at the bottom boundary', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
const before = ctx!.pages.map((p) => p.id);
|
||||
|
||||
act(() => ctx!.movePage(aboutId, 'down'));
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('moving a non-landing page into index 0 promotes it and demotes the old landing page to a real slug', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
// pages: [Home(index0, slug index), About]
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
const homeId = ctx!.pages[0].id;
|
||||
|
||||
act(() => ctx!.movePage(aboutId, 'up'));
|
||||
// pages: [About, Home]
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
|
||||
expect(ctx!.pages[0].slug).toBe('index'); // About is now the landing page
|
||||
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||
expect(demotedHome.slug).not.toBe('index');
|
||||
expect(demotedHome.slug).toBe('home');
|
||||
|
||||
// Exactly one 'index' slug, always at index 0.
|
||||
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||
expect(indexPages).toHaveLength(1);
|
||||
expect(ctx!.pages.indexOf(indexPages[0])).toBe(0);
|
||||
|
||||
// movePage does not touch the canvas.
|
||||
expect(deserializeMock).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('moving the current landing page down demotes it and promotes its neighbor', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
// pages: [Home(index0, slug index), About]
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
|
||||
act(() => ctx!.movePage(homeId, 'down'));
|
||||
// pages: [About, Home]
|
||||
|
||||
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
|
||||
|
||||
// Order changed and the landing invariant re-established: index 0
|
||||
// (now About) gets slug 'index'; the moved page (now at index 1, Home)
|
||||
// gets a real, non-'index' unique slug.
|
||||
expect(ctx!.pages[0].id).toBe(aboutId);
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||
expect(demotedHome.slug).not.toBe('index');
|
||||
expect(demotedHome.slug).toBe('home');
|
||||
|
||||
// Exactly one 'index' slug.
|
||||
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||
expect(indexPages).toHaveLength(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageContext.setLandingPage', () => {
|
||||
test('promotes an arbitrary page to index 0 and demotes the old landing page', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const contactId = ctx!.pages[2].id;
|
||||
|
||||
act(() => ctx!.setLandingPage(contactId));
|
||||
|
||||
expect(ctx!.pages[0].id).toBe(contactId);
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||
expect(demotedHome.slug).toBe('home');
|
||||
|
||||
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||
expect(indexPages).toHaveLength(1);
|
||||
|
||||
// setLandingPage does not touch the canvas.
|
||||
expect(deserializeMock).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('is a no-op when the page is already the landing page', () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const before = ctx!.pages.map((p) => ({ ...p }));
|
||||
|
||||
act(() => ctx!.setLandingPage(homeId));
|
||||
|
||||
expect(ctx!.pages).toEqual(before);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageContext.duplicatePage', () => {
|
||||
test('inserts a copy immediately after the source with a copied craftState, seo, and a unique slug', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
act(() => ctx!.addPage('Contact', 'contact'));
|
||||
await flushTimers();
|
||||
// pages: [Home, About, Contact]; Contact is currently active.
|
||||
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
act(() => ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' }));
|
||||
|
||||
act(() => ctx!.duplicatePage(aboutId));
|
||||
await flushTimers();
|
||||
|
||||
const names = ctx!.pages.map((p) => p.name);
|
||||
expect(names).toEqual(['Home', 'About', 'About copy', 'Contact']);
|
||||
|
||||
const copy = ctx!.pages[2];
|
||||
expect(copy.name).toBe('About copy');
|
||||
expect(copy.slug).toBe('about-copy');
|
||||
expect(copy.seo).toEqual({ metaTitle: 'About Us' });
|
||||
|
||||
// Every slug in the array is unique.
|
||||
const slugs = ctx!.pages.map((p) => p.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
|
||||
// Landing invariant untouched -- copy is never index 0.
|
||||
expect(ctx!.pages[0].slug).toBe('index');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('duplicating the ACTIVE page saves the live canvas into the copy (and the original)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
// Home is active by default. Simulate the user having made live edits.
|
||||
serializeReturn = '{"ROOT":{"live":"edit"}}';
|
||||
|
||||
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||
await flushTimers();
|
||||
|
||||
const copy = ctx!.pages[1];
|
||||
expect(copy.name).toBe('Home copy');
|
||||
expect(copy.craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||
|
||||
// Original page's stored state was also refreshed to the live canvas.
|
||||
expect(ctx!.pages[0].craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||
|
||||
// The canvas switched to the new copy.
|
||||
expect(ctx!.activePageId).toBe(copy.id);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('duplicating a non-active page copies its already-stored craftState (no live-canvas read)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
// Home is now inactive, stored with whatever it serialized to on switch.
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const homeCraftState = ctx!.pages[0].craftState;
|
||||
|
||||
// Switch the live serialize() return to something else, to prove
|
||||
// duplicating an inactive page does NOT read the live canvas.
|
||||
serializeReturn = '{"ROOT":{"unrelated":"currently-active-page-content"}}';
|
||||
|
||||
act(() => ctx!.duplicatePage(homeId));
|
||||
await flushTimers();
|
||||
|
||||
const copy = ctx!.pages.find((p) => p.name === 'Home copy')!;
|
||||
expect(copy.craftState).toBe(homeCraftState);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('duplicating a non-active page does NOT drop the outgoing active page\'s live unsaved edits (regression lock)', async () => {
|
||||
// Regression test for the Critical bug: duplicatePage(pageId) used to
|
||||
// call saveCurrentState() ONLY when pageId === the active page, yet
|
||||
// ALWAYS ended by tearing down the canvas via loadState() + switching
|
||||
// activePageId to the copy. If the duplicated page was NOT the active
|
||||
// one, the active page's live canvas edits were never serialized into
|
||||
// its slot before that teardown -- silently discarded. This asserts the
|
||||
// outgoing active page ('About') keeps its live-serialized craftState
|
||||
// after duplicating a DIFFERENT page ('Home').
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
// pages: [Home, About]; About is active (addPage switches to it).
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
expect(ctx!.activePageId).toBe(aboutId);
|
||||
|
||||
// Simulate the user having made live, unsaved edits to About (the
|
||||
// active page) that have not yet been serialized into pages[] state.
|
||||
const liveAboutEdit = '{"ROOT":{"live":"about-edit-not-yet-saved"}}';
|
||||
serializeReturn = liveAboutEdit;
|
||||
|
||||
// Duplicate a DIFFERENT page (Home), not the active one (About).
|
||||
act(() => ctx!.duplicatePage(homeId));
|
||||
await flushTimers();
|
||||
|
||||
// The outgoing active page's live edits must have been persisted into
|
||||
// its own slot before the canvas was torn down and switched away.
|
||||
const aboutAfter = ctx!.pages.find((p) => p.id === aboutId)!;
|
||||
expect(aboutAfter.craftState).toBe(liveAboutEdit);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
deserializeMock.mockClear();
|
||||
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||
await flushTimers();
|
||||
|
||||
expect(deserializeMock).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { PageData } from '../types';
|
||||
import { PageData, PageSeo } from '../types';
|
||||
import { SerializedTreeNode } from '../types/sitesmith';
|
||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
||||
@@ -18,9 +18,33 @@ interface PageContextValue {
|
||||
addPage: (name: string, slug: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
renamePage: (pageId: string, name: string, slug: string) => void;
|
||||
/**
|
||||
* Duplicates `pageId`, inserting the copy immediately after the source in
|
||||
* `pages` and switching the canvas to the new copy. If `pageId` is the
|
||||
* active page, its current on-canvas state is saved first so the copy
|
||||
* (and the original) both reflect what's actually on screen. The copy
|
||||
* gets its own unique slug (never `'index'` -- it's never at index 0) and
|
||||
* a name of `"<source name> copy"`; its `seo` is copied from the source.
|
||||
*/
|
||||
duplicatePage: (pageId: string) => void;
|
||||
/**
|
||||
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
|
||||
* the adjacent page; no-op at either end). Does NOT touch the live
|
||||
* canvas -- only list order changes. Re-applies the landing-page
|
||||
* invariant afterward (see `applyLandingInvariant`) since a reorder can
|
||||
* move a different page into/out of index 0.
|
||||
*/
|
||||
movePage: (pageId: string, direction: 'up' | 'down') => void;
|
||||
/**
|
||||
* Moves `pageId` to index 0 (making it the new landing page) and
|
||||
* re-applies the landing-page invariant. Does NOT touch the live canvas.
|
||||
*/
|
||||
setLandingPage: (pageId: string) => void;
|
||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
setHeaderCraftState: (craftState: string) => void;
|
||||
setFooterCraftState: (craftState: string) => void;
|
||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
|
||||
/**
|
||||
* Bookkeeping-only: point `activePageId` at an already-loaded page without
|
||||
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
|
||||
@@ -186,6 +210,10 @@ const PageContext = createContext<PageContextValue>({
|
||||
addPage: () => {},
|
||||
deletePage: () => {},
|
||||
renamePage: () => {},
|
||||
duplicatePage: () => {},
|
||||
movePage: () => {},
|
||||
setLandingPage: () => {},
|
||||
updatePageSeo: () => {},
|
||||
setHeaderCraftState: () => {},
|
||||
setFooterCraftState: () => {},
|
||||
setPagesCraftState: () => {},
|
||||
@@ -226,6 +254,59 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
||||
return `${base}-${i}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-establishes the landing-page invariant on a REORDERED pages array: the
|
||||
* page now at index 0 is the landing page and its slug is locked to
|
||||
* `'index'` (regardless of whatever slug it held before it was moved there);
|
||||
* every other page keeps its slug UNLESS it's the page that previously held
|
||||
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
|
||||
* unique slug of its own (derived from its name) since a page can no longer
|
||||
* publish to `index.html` from anywhere but index 0.
|
||||
*
|
||||
* Pure function of the array -- used by both `movePage` (swap two adjacent
|
||||
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
|
||||
* shared "fix the invariant up after reordering" step, and directly
|
||||
* unit-testable without mounting `PageProvider`.
|
||||
*
|
||||
* Normally at most one page enters with slug `'index'` (true for any array
|
||||
* that already satisfied the invariant before the reorder that produced this
|
||||
* input) -- exactly the case both callers hand it. Defensively, though, a
|
||||
* STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
|
||||
* loaded data that predates this invariant) is also demoted rather than left
|
||||
* as a duplicate -- see the running `usedSlugs` accumulation below.
|
||||
*/
|
||||
export function applyLandingInvariant(pages: PageData[]): PageData[] {
|
||||
if (pages.length === 0) return pages;
|
||||
|
||||
// Slugs that must not be collided into: 'index' (reserved for whoever
|
||||
// ends up at index 0) plus every non-landing page's existing slug except
|
||||
// any demoted page's (it currently holds 'index' and is about to be given
|
||||
// a new one). Computed upfront, over the WHOLE array, so a demoted page's
|
||||
// new slug is checked against every other page regardless of array order
|
||||
// -- checking only "slugs seen so far" while walking the array would miss
|
||||
// a collision against a page that appears LATER in the list than the
|
||||
// demoted one. Mutated (pushed to) as pages are demoted below so that two
|
||||
// demoted pages in the same pass can't collide with EACH OTHER either.
|
||||
const usedSlugs: string[] = ['index'];
|
||||
for (let i = 1; i < pages.length; i++) {
|
||||
if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
|
||||
}
|
||||
|
||||
return pages.map((page, i) => {
|
||||
if (i === 0) {
|
||||
return page.slug === 'index' ? page : { ...page, slug: 'index' };
|
||||
}
|
||||
if (page.slug === 'index') {
|
||||
// Demoted landing page (or a stray extra 'index' page -- see doc
|
||||
// comment above) -- give it a real, unique slug of its own.
|
||||
const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
|
||||
usedSlugs.push(newSlug);
|
||||
return { ...page, slug: newSlug };
|
||||
}
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE: PageData = {
|
||||
id: 'home',
|
||||
name: 'Home',
|
||||
@@ -406,6 +487,101 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
[loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Duplicates `pageId`: inserts a copy immediately after the source in
|
||||
* `pages` and switches the live canvas to it. See the doc comment on
|
||||
* `PageContextValue.duplicatePage`.
|
||||
*/
|
||||
const duplicatePage = useCallback(
|
||||
(pageId: string) => {
|
||||
// Always persist whatever is on the live canvas back into its page
|
||||
// slot BEFORE any teardown below (same as addPage/switchPage/deletePage
|
||||
// do unconditionally). Without this, duplicating a page OTHER than the
|
||||
// active one would tear down and switch the canvas via loadState()
|
||||
// further down without ever serializing the outgoing active page's
|
||||
// live edits into its slot -- silently discarding them.
|
||||
saveCurrentState();
|
||||
|
||||
const isActive = pageId === activePageIdRef.current;
|
||||
const source = pagesRef.current.find((p) => p.id === pageId);
|
||||
if (!source) return;
|
||||
|
||||
// If the source IS the active page, saveCurrentState() above just
|
||||
// wrote the live canvas into `source.craftState`'s slot -- but
|
||||
// `pagesRef.current` (captured above) may still be the pre-update
|
||||
// snapshot depending on render timing, so ask Craft.js directly for
|
||||
// the same value rather than re-reading the ref. If the source is a
|
||||
// NON-active page, its stored craftState is untouched by saving the
|
||||
// (different) active page above, so use it as-is.
|
||||
const sourceCraftState = isActive ? query.serialize() : source.craftState;
|
||||
const otherSlugs = pagesRef.current.map((p) => p.slug);
|
||||
const copyId = nextPageId();
|
||||
const copyName = `${source.name} copy`;
|
||||
// The copy is always inserted AFTER the source (index >= 1), so it
|
||||
// never needs the reserved 'index' slug -- a normal unique slug always
|
||||
// applies here regardless of whether the source itself is the landing
|
||||
// page.
|
||||
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
|
||||
const copy: PageData = {
|
||||
id: copyId,
|
||||
name: copyName,
|
||||
slug: copySlug,
|
||||
craftState: sourceCraftState,
|
||||
seo: source.seo ? { ...source.seo } : undefined,
|
||||
};
|
||||
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(idx + 1, 0, copy);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Switch the canvas to the new copy so the user lands on it, same as
|
||||
// addPage switching to the freshly created page.
|
||||
loadState(copy.craftState, EMPTY_CANVAS);
|
||||
setActivePageId(copyId);
|
||||
activePageIdRef.current = copyId;
|
||||
},
|
||||
[query, saveCurrentState, loadState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reorders `pageId` one slot up or down (swap with the adjacent page).
|
||||
* Pure list-order change -- does not touch the live canvas. See the doc
|
||||
* comment on `PageContextValue.movePage`.
|
||||
*/
|
||||
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1) return prev;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
|
||||
|
||||
const next = [...prev];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Moves `pageId` to index 0, making it the new landing page. Pure list-
|
||||
* order change -- does not touch the live canvas. See the doc comment on
|
||||
* `PageContextValue.setLandingPage`.
|
||||
*/
|
||||
const setLandingPage = useCallback((pageId: string) => {
|
||||
setPages((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === pageId);
|
||||
if (idx <= 0) return prev; // already the landing page, or not found
|
||||
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(idx, 1);
|
||||
next.unshift(moved);
|
||||
return applyLandingInvariant(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p, i) => {
|
||||
@@ -421,6 +597,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||
const updatePageSeo = useCallback((pageId: string, seo: Partial<PageSeo>) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p) => (p.id === pageId ? { ...p, seo: { ...p.seo, ...seo } } : p)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to set the header craft state */
|
||||
const setHeaderCraftState = useCallback((craftState: string) => {
|
||||
setHeaderPage((prev) => ({ ...prev, craftState }));
|
||||
@@ -442,7 +625,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
|
||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
|
||||
setPages(pagesData.map((p, i) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -451,6 +634,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// ALWAYS the landing page → slug 'index' → file index.html.
|
||||
slug: i === 0 ? 'index' : p.slug,
|
||||
craftState: p.craftState,
|
||||
seo: p.seo,
|
||||
})));
|
||||
}, []);
|
||||
|
||||
@@ -545,6 +729,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
addPage,
|
||||
deletePage,
|
||||
renamePage,
|
||||
duplicatePage,
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
setHeaderCraftState,
|
||||
setFooterCraftState,
|
||||
setPagesCraftState,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageProvider, usePages } from './PageContext';
|
||||
|
||||
/**
|
||||
* PKG-H: `updatePageSeo(pageId, seo)` merges `seo` fields onto the target
|
||||
* page's existing `seo` (creating it if absent), leaving every other page
|
||||
* and every other field on the target page untouched -- mirrors
|
||||
* `renamePage`'s existing merge-by-id pattern.
|
||||
*/
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => '{}' },
|
||||
actions: { deserialize: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe('PageContext.updatePageSeo', () => {
|
||||
test('sets seo on a page that previously had none', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
expect(ctx!.pages[0].seo).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'Hello World', noindex: true });
|
||||
});
|
||||
|
||||
expect(ctx!.pages[0].seo).toEqual({ metaTitle: 'Hello World', noindex: true });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('merges new fields onto existing seo without clobbering untouched fields', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'First', metaDescription: 'Desc' });
|
||||
});
|
||||
act(() => {
|
||||
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'Second' });
|
||||
});
|
||||
|
||||
expect(ctx!.pages[0].seo).toEqual({ metaTitle: 'Second', metaDescription: 'Desc' });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('only touches the targeted page, leaving other pages untouched', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ctx!.addPage('About', 'about');
|
||||
});
|
||||
|
||||
const homeId = ctx!.pages[0].id;
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
|
||||
act(() => {
|
||||
ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' });
|
||||
});
|
||||
|
||||
expect(ctx!.pages.find((p) => p.id === aboutId)?.seo).toEqual({ metaTitle: 'About Us' });
|
||||
expect(ctx!.pages.find((p) => p.id === homeId)?.seo).toBeUndefined();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@ export interface SiteDesign {
|
||||
|
||||
// Site-wide custom code
|
||||
headCode: string;
|
||||
|
||||
/** Site-wide favicon URL (one per site, not per-page). Empty string = none. */
|
||||
favicon: string;
|
||||
}
|
||||
|
||||
export interface SiteDesignContextValue {
|
||||
@@ -52,6 +55,7 @@ export const DEFAULT_SITE_DESIGN: SiteDesign = {
|
||||
buttonRadius: '8px',
|
||||
navStyle: 'light',
|
||||
headCode: '',
|
||||
favicon: '',
|
||||
};
|
||||
|
||||
const SiteDesignContext = createContext<SiteDesignContextValue>({
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||||
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||
|
||||
/**
|
||||
* Real-`@craftjs/core` integration coverage for duplicate/paste.
|
||||
@@ -67,10 +67,39 @@ const INITIAL_STATE = JSON.stringify({
|
||||
},
|
||||
});
|
||||
|
||||
// A second, independent "page" state -- distinct node ids from INITIAL_STATE,
|
||||
// simulating what PageContext.switchPage does: `query.serialize()` the
|
||||
// current page, then `actions.deserialize()` the target page's stored
|
||||
// state, replacing the ENTIRE node map. `social-1` (copied from page A)
|
||||
// does not exist anywhere in this state.
|
||||
const PAGE_B_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: ['page-b-heading-1'],
|
||||
linkedNodes: {},
|
||||
},
|
||||
'page-b-heading-1': {
|
||||
type: { resolvedName: 'Heading' },
|
||||
isCanvas: false,
|
||||
props: { text: 'Page B Heading', level: 'h2' },
|
||||
displayName: 'Heading',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
parent: 'ROOT',
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
let harness: EditorHarness | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
setClipboardNodeId(null);
|
||||
setClipboardTree(null);
|
||||
if (harness) {
|
||||
harness.unmount();
|
||||
harness = null;
|
||||
@@ -158,7 +187,7 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(getClipboardNodeId()).toBe('social-1');
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||
|
||||
// Select heading-1 (a sibling), then paste -- should land as a sibling
|
||||
// of heading-1's parent (ROOT), with brand-new ids.
|
||||
@@ -190,4 +219,76 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
||||
expect(pastedProps.links).not.toBe(originalProps.links);
|
||||
expect(pastedProps).toEqual(originalProps);
|
||||
});
|
||||
|
||||
test('CROSS-PAGE copy/paste: copy on page A, switch to page B, paste -- the node appears on page B', () => {
|
||||
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||
|
||||
const Consumer: React.FC = () => {
|
||||
useKeyboardShortcuts();
|
||||
return null;
|
||||
};
|
||||
harness.mountChild(<Consumer />);
|
||||
|
||||
// --- Page A: select + copy social-1. ---
|
||||
harness.act(() => {
|
||||
harness!.actions.selectNode('social-1');
|
||||
});
|
||||
harness.act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||
|
||||
// --- Switch to page B: exactly what PageContext.switchPage does --
|
||||
// serialize (discarded here, a real page switch would stash it) then
|
||||
// deserialize the target page's state, replacing the ENTIRE node map.
|
||||
// `social-1` no longer exists anywhere in `query` after this. ---
|
||||
harness.act(() => {
|
||||
harness!.actions.deserialize(PAGE_B_STATE);
|
||||
});
|
||||
expect(harness.query.getNodes()['social-1']).toBeUndefined();
|
||||
|
||||
// Select page B's only node, then paste.
|
||||
harness.act(() => {
|
||||
harness!.actions.selectNode('page-b-heading-1');
|
||||
});
|
||||
|
||||
const beforePaste = Object.keys(harness.query.getNodes());
|
||||
expect(() => {
|
||||
harness!.act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
const afterPaste = Object.keys(harness.query.getNodes());
|
||||
|
||||
// The regression this guards against: with the old id-based clipboard,
|
||||
// `query.node('social-1').get()` returns undefined once page B is
|
||||
// loaded, so the paste handler's guard silently no-ops -- NOTHING gets
|
||||
// added. With the tree-snapshot clipboard, the copied subtree is
|
||||
// detached from any live query and pastes onto page B regardless.
|
||||
expect(afterPaste.length).toBe(beforePaste.length + 1);
|
||||
|
||||
const pastedId = afterPaste.find((id) => !beforePaste.includes(id))!;
|
||||
expect(pastedId).toBeDefined();
|
||||
|
||||
const pastedNode = harness.query.node(pastedId).get();
|
||||
expect(pastedNode.data.displayName).toBe('Social Links');
|
||||
expect(pastedNode.data.props.links).toEqual([
|
||||
{ platform: 'facebook', url: 'https://facebook.com/original' },
|
||||
]);
|
||||
|
||||
// Actually landed on page B's tree, as a sibling of the selected node.
|
||||
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||
expect(rootChildren).toContain(pastedId);
|
||||
|
||||
// Real DOM assertion: the pasted SocialLinks component is actually
|
||||
// rendered on the (now page B) canvas.
|
||||
expect(
|
||||
harness.container.querySelectorAll('a[href="https://facebook.com/original"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,34 @@ export interface WhpConfig {
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-page SEO/meta overrides (PKG-H contract §1). All fields optional --
|
||||
* an absent `seo` (or absent individual field) means "no override, fall
|
||||
* back to the page name / no tag emitted", preserving back-compat for
|
||||
* every project saved before this field existed.
|
||||
*/
|
||||
export interface PageSeo {
|
||||
/** Overrides <title>; when empty, <title> falls back to page.name. */
|
||||
metaTitle?: string;
|
||||
/** <meta name="description"> + og:description. */
|
||||
metaDescription?: string;
|
||||
/** og:title; when empty falls back to metaTitle || page.name. */
|
||||
ogTitle?: string;
|
||||
/** Absolute or site-relative image URL (via AssetPicker mediaType=image). */
|
||||
ogImage?: string;
|
||||
/** Default 'summary_large_image' when ogImage set, else 'summary'. */
|
||||
twitterCard?: 'summary' | 'summary_large_image';
|
||||
/** Emits <meta name="robots" content="noindex, nofollow">. */
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
craftState: string | null;
|
||||
/** NEW -- optional; absent === no SEO overrides (back-compat). */
|
||||
seo?: PageSeo;
|
||||
}
|
||||
|
||||
export interface AssetData {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { exportBodyHtml } from './html-export';
|
||||
import { exportBodyHtml, exportToHtml, ExportOptions } from './html-export';
|
||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
/**
|
||||
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
|
||||
@@ -97,3 +98,240 @@ describe('renderNode div-fallback allowlists props.tag', () => {
|
||||
expect(html).toContain('</section>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
|
||||
* variables emitted into `wrapInDocument`'s <head> (exercised via
|
||||
* `exportToHtml`, since `exportBodyHtml` intentionally stays body-only --
|
||||
* contract §7). These assert the exact "only emit when set" / sanitization
|
||||
* / ordering rules the backend implementer's `generateCompiledHTML` must
|
||||
* mirror byte-for-byte for editor Preview === published output.
|
||||
*/
|
||||
describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
|
||||
const EMPTY_ROOT_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
function exportWith(options: ExportOptions): string {
|
||||
return exportToHtml(EMPTY_ROOT_STATE, options).html;
|
||||
}
|
||||
|
||||
describe('back-compat: no seo, no favicon, no design passed', () => {
|
||||
const html = exportWith({ title: 'Legacy Page' });
|
||||
|
||||
test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => {
|
||||
expect(html).toContain('<title>Legacy Page</title>');
|
||||
expect(html).not.toContain('name="robots"');
|
||||
expect(html).not.toContain('name="description"');
|
||||
expect(html).not.toContain('property="og:description"');
|
||||
expect(html).not.toContain('property="og:image"');
|
||||
expect(html).not.toContain('rel="icon"');
|
||||
// og:title still always emits (falls back to title) -- og:type is a
|
||||
// fixed tag too -- neither depends on seo being set.
|
||||
expect(html).toContain('<meta property="og:title" content="Legacy Page">');
|
||||
expect(html).toContain('<meta property="og:type" content="website">');
|
||||
// twitter:card only emits when ogImage or description is set -- neither
|
||||
// is here, so it must be absent.
|
||||
expect(html).not.toContain('name="twitter:card"');
|
||||
});
|
||||
|
||||
test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => {
|
||||
expect(html).toContain(':root{--wsb-primary:');
|
||||
expect(html).toContain('--wsb-button-radius:');
|
||||
expect(html).toContain('body{font-family:var(--wsb-body-font)');
|
||||
expect(html).toContain('a:focus-visible');
|
||||
expect(html).toContain('prefers-reduced-motion:reduce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => {
|
||||
test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => {
|
||||
const html = exportWith({ title: 'Page', description: 'A great page.' });
|
||||
expect(html).toContain('<meta name="description" content="A great page.">');
|
||||
expect(html).toContain('<meta property="og:description" content="A great page.">');
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
||||
});
|
||||
|
||||
test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => {
|
||||
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' });
|
||||
expect(html).toContain('<meta property="og:image" content="/uploads/hero.jpg">');
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
||||
expect(html).not.toContain('name="description"');
|
||||
});
|
||||
|
||||
test('explicit twitterCard overrides the derived default', () => {
|
||||
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' });
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
||||
});
|
||||
|
||||
test('ogTitle set -> og:title uses it instead of the page title', () => {
|
||||
const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' });
|
||||
expect(html).toContain('<meta property="og:title" content="Custom Share Title">');
|
||||
expect(html).not.toContain('<meta property="og:title" content="Page Title">');
|
||||
});
|
||||
|
||||
test('noindex true -> robots meta appears; false/absent -> it does not', () => {
|
||||
expect(exportWith({ title: 'Page', noindex: true })).toContain('<meta name="robots" content="noindex, nofollow">');
|
||||
expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"');
|
||||
expect(exportWith({ title: 'Page' })).not.toContain('name="robots"');
|
||||
});
|
||||
|
||||
test('favicon set -> icon link appears with the exact URL', () => {
|
||||
const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' });
|
||||
expect(html).toContain('<link rel="icon" href="/uploads/favicon.png">');
|
||||
});
|
||||
|
||||
test('favicon absent -> no icon link', () => {
|
||||
expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitization neutralizes malicious values', () => {
|
||||
test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => {
|
||||
const html = exportWith({
|
||||
title: 'Page',
|
||||
ogImage: 'javascript:alert(1)',
|
||||
favicon: 'javascript:alert(1)',
|
||||
description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty
|
||||
});
|
||||
expect(html).not.toContain('javascript:alert');
|
||||
expect(html).not.toContain('property="og:image"');
|
||||
expect(html).not.toContain('rel="icon"');
|
||||
// og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'.
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
||||
});
|
||||
|
||||
test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => {
|
||||
const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' });
|
||||
expect(html).toContain('<meta property="og:image" content="data:image/png;base64,AAAA">');
|
||||
});
|
||||
|
||||
test('an attribute-breakout metaDescription is escaped, not left raw', () => {
|
||||
const payload = '"><script>alert(1)</script>';
|
||||
const html = exportWith({ title: 'Page', description: payload });
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('"><script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('a malicious design-token value cannot break out of the :root{} block', () => {
|
||||
const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '</style><script>alert(1)</script>' };
|
||||
const html = exportWith({ title: 'Page', design });
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).not.toContain('</style><script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe(':root token block', () => {
|
||||
test('emits all 16 contract-named CSS variables with sanitized values, in order', () => {
|
||||
const design: SiteDesign = {
|
||||
...DEFAULT_SITE_DESIGN,
|
||||
primaryColor: '#111111',
|
||||
secondaryColor: '#222222',
|
||||
accentColor: '#333333',
|
||||
linkColor: '#444444',
|
||||
successColor: '#555555',
|
||||
warningColor: '#666666',
|
||||
errorColor: '#777777',
|
||||
backgroundColor: '#888888',
|
||||
textColor: '#999999',
|
||||
mutedTextColor: '#aaaaaa',
|
||||
borderColor: '#bbbbbb',
|
||||
borderRadius: '12px',
|
||||
headingFont: 'Georgia, serif',
|
||||
bodyFont: 'Verdana, sans-serif',
|
||||
buttonFont: 'Tahoma, sans-serif',
|
||||
buttonRadius: '4px',
|
||||
};
|
||||
const html = exportWith({ title: 'Page', design });
|
||||
|
||||
const rootMatch = html.match(/:root\{([^}]*)\}/);
|
||||
expect(rootMatch).not.toBeNull();
|
||||
const rootBlock = rootMatch![1];
|
||||
|
||||
const expectedOrder = [
|
||||
['--wsb-primary', '#111111'],
|
||||
['--wsb-secondary', '#222222'],
|
||||
['--wsb-accent', '#333333'],
|
||||
['--wsb-link', '#444444'],
|
||||
['--wsb-success', '#555555'],
|
||||
['--wsb-warning', '#666666'],
|
||||
['--wsb-error', '#777777'],
|
||||
['--wsb-bg', '#888888'],
|
||||
['--wsb-text', '#999999'],
|
||||
['--wsb-muted', '#aaaaaa'],
|
||||
['--wsb-border', '#bbbbbb'],
|
||||
['--wsb-radius', '12px'],
|
||||
['--wsb-heading-font', 'Georgia, serif'],
|
||||
['--wsb-body-font', 'Verdana, sans-serif'],
|
||||
['--wsb-button-font', 'Tahoma, sans-serif'],
|
||||
['--wsb-button-radius', '4px'],
|
||||
];
|
||||
|
||||
let lastIndex = -1;
|
||||
for (const [varName, value] of expectedOrder) {
|
||||
const decl = `${varName}:${value}`;
|
||||
expect(rootBlock).toContain(decl);
|
||||
const idx = rootBlock.indexOf(decl);
|
||||
expect(idx).toBeGreaterThan(lastIndex);
|
||||
lastIndex = idx;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a11y/perf CSS', () => {
|
||||
test('focus-visible outline rule is present', () => {
|
||||
const html = exportWith({ title: 'Page' });
|
||||
expect(html).toContain('a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}');
|
||||
});
|
||||
|
||||
test('prefers-reduced-motion rule is present', () => {
|
||||
const html = exportWith({ title: 'Page' });
|
||||
expect(html).toContain('@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Fonts link', () => {
|
||||
test('always includes display=swap, with no design passed (fallback full link)', () => {
|
||||
const html = exportWith({ title: 'Page', includeFonts: true });
|
||||
expect(html).toContain('display=swap');
|
||||
expect(html).toContain('fonts.googleapis.com');
|
||||
});
|
||||
|
||||
test('subsets to only the used families when all 3 design fonts are known presets', () => {
|
||||
const design: SiteDesign = {
|
||||
...DEFAULT_SITE_DESIGN,
|
||||
headingFont: 'Playfair Display, serif',
|
||||
bodyFont: 'Inter, sans-serif',
|
||||
buttonFont: 'Inter, sans-serif',
|
||||
};
|
||||
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
||||
expect(html).toContain('display=swap');
|
||||
expect(html).toContain('family=Playfair+Display');
|
||||
expect(html).toContain('family=Inter');
|
||||
// Not one of the 3 used fonts -- subset link must not pull it in.
|
||||
expect(html).not.toContain('family=Merriweather');
|
||||
});
|
||||
|
||||
test('falls back to the full multi-font link when a design font is not a known preset', () => {
|
||||
const design: SiteDesign = {
|
||||
...DEFAULT_SITE_DESIGN,
|
||||
headingFont: 'Comic Sans MS, cursive',
|
||||
};
|
||||
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
||||
expect(html).toContain('display=swap');
|
||||
// Full fallback link carries every preset family, including ones the
|
||||
// design object doesn't reference -- a component using ANY preset
|
||||
// never silently loses its font.
|
||||
expect(html).toContain('family=Merriweather');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { componentResolver } from '../components/resolver';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
import { escapeHtml, escapeAttr } from './escape';
|
||||
import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
|
||||
import { sanitizeContainerTag } from '../components/layout/Container';
|
||||
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
|
||||
export interface ExportOptions {
|
||||
title?: string;
|
||||
includeFonts?: boolean;
|
||||
minifyCss?: boolean;
|
||||
headCode?: string;
|
||||
/** PageSeo.metaDescription -- also feeds og:description when set. */
|
||||
description?: string;
|
||||
/** PageSeo.ogTitle -- falls back to `title` (which itself already folds in metaTitle) when empty. */
|
||||
ogTitle?: string;
|
||||
/** PageSeo.ogImage -- absolute or site-relative image URL. */
|
||||
ogImage?: string;
|
||||
/** PageSeo.twitterCard -- default 'summary_large_image' when ogImage set, else 'summary'. */
|
||||
twitterCard?: 'summary' | 'summary_large_image';
|
||||
/** PageSeo.noindex -- emits <meta name="robots" content="noindex, nofollow">. */
|
||||
noindex?: boolean;
|
||||
/** SiteDesign.favicon -- site-wide, one per site. */
|
||||
favicon?: 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. */
|
||||
design?: SiteDesign;
|
||||
}
|
||||
|
||||
interface ResolverMap {
|
||||
@@ -138,6 +155,149 @@ const GOOGLE_FONTS_LINK = `<link rel="preconnect" href="https://fonts.googleapis
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`;
|
||||
|
||||
// Google Fonts family= params for each of the 8 presets in constants/presets.ts
|
||||
// FONT_FAMILIES, keyed by the CSS font-family name (the part before the first
|
||||
// comma in e.g. 'Playfair Display, serif'). Used to build a best-effort
|
||||
// SUBSET fonts link (perf: §3) when every design font resolves to a known
|
||||
// preset; otherwise buildFontsLink() falls back to the full GOOGLE_FONTS_LINK
|
||||
// above (which already covers all 8 and already has &display=swap) so a
|
||||
// component using a preset outside the design's 3 font fields never loses
|
||||
// its font.
|
||||
const GOOGLE_FONT_PARAMS: Record<string, string> = {
|
||||
'Inter': 'family=Inter:wght@300;400;500;600;700',
|
||||
'Roboto': 'family=Roboto:wght@300;400;500;700',
|
||||
'Open Sans': 'family=Open+Sans:wght@300;400;600;700',
|
||||
'Poppins': 'family=Poppins:wght@300;400;500;600;700',
|
||||
'Montserrat': 'family=Montserrat:wght@300;400;500;600;700',
|
||||
'Playfair Display': 'family=Playfair+Display:wght@400;600;700',
|
||||
'Merriweather': 'family=Merriweather:wght@300;400;700',
|
||||
'Source Code Pro': 'family=Source+Code+Pro:wght@400;500;600',
|
||||
};
|
||||
|
||||
/** Extracts the CSS font-family name from a value like 'Inter, sans-serif' -> 'Inter'. */
|
||||
function extractFontName(value: string | undefined): string {
|
||||
return (value || '').split(',')[0].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort Google Fonts link (contract §3): tries to build a SUBSET link
|
||||
* containing only the design's heading/body/button fonts (+ display=swap).
|
||||
* Falls back to the full multi-font GOOGLE_FONTS_LINK -- which still has
|
||||
* display=swap -- whenever any of those three fonts isn't one of the 8 known
|
||||
* presets, so a font a component actually uses is never silently dropped.
|
||||
*/
|
||||
export function buildFontsLink(design?: SiteDesign): string {
|
||||
if (!design) return GOOGLE_FONTS_LINK;
|
||||
const families = [design.headingFont, design.bodyFont, design.buttonFont]
|
||||
.map(extractFontName)
|
||||
.filter((f, i, arr) => f !== '' && arr.indexOf(f) === i);
|
||||
if (families.length === 0) return GOOGLE_FONTS_LINK;
|
||||
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]).filter((p): p is string => !!p);
|
||||
if (params.length !== families.length) return GOOGLE_FONTS_LINK;
|
||||
return `<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?${params.join('&')}&display=swap" rel="stylesheet">`;
|
||||
}
|
||||
|
||||
// ---------- Design-token CSS-variable block (contract §2) ----------
|
||||
|
||||
// Ordered [cssVarName, SiteDesign key] pairs -- exact order/names from the
|
||||
// PKG-H shared contract §2. Both the frontend (here) and the backend
|
||||
// (site-builder.php) emit this same 16-line block for the same data so
|
||||
// editor Preview === published output.
|
||||
const TOKEN_VARS: Array<[string, keyof SiteDesign]> = [
|
||||
['--wsb-primary', 'primaryColor'],
|
||||
['--wsb-secondary', 'secondaryColor'],
|
||||
['--wsb-accent', 'accentColor'],
|
||||
['--wsb-link', 'linkColor'],
|
||||
['--wsb-success', 'successColor'],
|
||||
['--wsb-warning', 'warningColor'],
|
||||
['--wsb-error', 'errorColor'],
|
||||
['--wsb-bg', 'backgroundColor'],
|
||||
['--wsb-text', 'textColor'],
|
||||
['--wsb-muted', 'mutedTextColor'],
|
||||
['--wsb-border', 'borderColor'],
|
||||
['--wsb-radius', 'borderRadius'],
|
||||
['--wsb-heading-font', 'headingFont'],
|
||||
['--wsb-body-font', 'bodyFont'],
|
||||
['--wsb-button-font', 'buttonFont'],
|
||||
['--wsb-button-radius', 'buttonRadius'],
|
||||
];
|
||||
|
||||
// Fixed token-base CSS (contract §2) -- makes the tokens actually take effect
|
||||
// globally; component inline styles still override via specificity.
|
||||
const TOKEN_BASE_CSS = `body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}`;
|
||||
|
||||
// Fixed a11y/perf CSS (contract §3), emitted for every published/preview page.
|
||||
const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}`;
|
||||
|
||||
/**
|
||||
* Builds the `:root{...}` design-token block + the fixed token-base CSS +
|
||||
* the fixed a11y/perf CSS (contract §2/§3). Every token value is sanitized
|
||||
* through `cssValue()` before interpolation -- it sits raw inside a
|
||||
* `:root{ }` CSS-element context (not a string-quoted context), so a
|
||||
* malicious value could otherwise break out of the block / the `<style>`
|
||||
* element itself.
|
||||
*/
|
||||
export function buildTokenCss(design: SiteDesign): string {
|
||||
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
|
||||
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
|
||||
}
|
||||
|
||||
// ---------- <head> SEO/meta block (contract §4) ----------
|
||||
|
||||
/**
|
||||
* Builds the ordered SEO/meta tag block from `<meta name="robots">` (only
|
||||
* when noindex) through the favicon `<link>` -- 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`)
|
||||
* collapses to `ogTitle || title` once `title` itself already folds in the
|
||||
* metaTitle fallback, so no separate metaTitle parameter is needed here.
|
||||
* Each optional tag is emitted ONLY when its source value is non-empty --
|
||||
* no empty `content=""` tags ever ship.
|
||||
*/
|
||||
export function buildSeoMeta(options: ExportOptions, title: string): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options.noindex) {
|
||||
lines.push('<meta name="robots" content="noindex, nofollow">');
|
||||
}
|
||||
|
||||
lines.push(`<title>${escapeHtml(title)}</title>`);
|
||||
|
||||
const description = options.description || '';
|
||||
if (description) {
|
||||
lines.push(`<meta name="description" content="${escapeAttr(description)}">`);
|
||||
}
|
||||
|
||||
const ogTitle = options.ogTitle || title;
|
||||
lines.push(`<meta property="og:title" content="${escapeAttr(ogTitle)}">`);
|
||||
|
||||
if (description) {
|
||||
lines.push(`<meta property="og:description" content="${escapeAttr(description)}">`);
|
||||
}
|
||||
|
||||
const ogImage = options.ogImage ? safeImageUrl(options.ogImage) : '';
|
||||
if (ogImage) {
|
||||
lines.push(`<meta property="og:image" content="${escapeAttr(ogImage)}">`);
|
||||
}
|
||||
|
||||
lines.push('<meta property="og:type" content="website">');
|
||||
|
||||
if (ogImage || description) {
|
||||
const card = options.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
|
||||
lines.push(`<meta name="twitter:card" content="${escapeAttr(card)}">`);
|
||||
}
|
||||
|
||||
const favicon = options.favicon ? safeImageUrl(options.favicon) : '';
|
||||
if (favicon) {
|
||||
lines.push(`<link rel="icon" href="${escapeAttr(favicon)}">`);
|
||||
}
|
||||
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
const RESPONSIVE_CSS = `
|
||||
@media (max-width: 768px) {
|
||||
[style*="display: flex"][style*="flex-direction: row"],
|
||||
@@ -191,8 +351,15 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS;
|
||||
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS;
|
||||
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS;
|
||||
const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : '';
|
||||
// §7 back-compat: even a caller that doesn't pass `design` (legacy call
|
||||
// sites, e.g. tests not yet updated) still gets the token/base/a11y CSS --
|
||||
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
|
||||
// correct stand-in rather than skipping the block entirely.
|
||||
const design = options.design || DEFAULT_SITE_DESIGN;
|
||||
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design)}` : '';
|
||||
const headCode = options.headCode ? `\n ${options.headCode}` : '';
|
||||
const seoMeta = buildSeoMeta(options, title);
|
||||
const tokenCss = buildTokenCss(design);
|
||||
|
||||
// Only include animation CSS + script if body contains data-animation
|
||||
const hasAnimations = bodyHtml.includes('data-animation');
|
||||
@@ -204,8 +371,8 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>${fonts}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}</style>${headCode}
|
||||
${seoMeta}${fonts}
|
||||
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${headCode}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}${animationScript}
|
||||
|
||||
Reference in New Issue
Block a user