Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25dfcbb725 | ||
|
|
9bf78fd72d | ||
|
|
2dcc2b4d21 | ||
|
|
4e0fc78a30 | ||
|
|
85dfe181aa | ||
|
|
a698f014b0 | ||
|
|
204ea5e078 | ||
|
|
0291ddce9a | ||
|
|
f7da654c11 | ||
|
|
54572f648a |
@@ -20,3 +20,14 @@ describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () =>
|
||||
expect(html).toContain('© 2026 MySite. All rights reserved.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Footer as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
|
||||
interface FooterProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
export const Footer: UserComponent<FooterProps> = ({
|
||||
@@ -92,6 +97,11 @@ Footer.craft = {
|
||||
fontSize: '14px',
|
||||
padding: '24px 20px',
|
||||
},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -43,6 +43,29 @@ describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
|
||||
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
|
||||
});
|
||||
|
||||
test('no download prop -> no download attribute emitted', () => {
|
||||
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
|
||||
expect(html).not.toContain('download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Logo as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo.toHtml text-logo styling sanitization', () => {
|
||||
test('a quote-breakout color does not escape the span style attribute', () => {
|
||||
const malicious = 'red" onmouseover="alert(1)';
|
||||
|
||||
@@ -12,11 +12,18 @@ interface LogoProps {
|
||||
imageSrc?: string;
|
||||
imageWidth?: string;
|
||||
href?: string;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
|
||||
download?: boolean;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
color?: string;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
@@ -27,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
imageSrc = '',
|
||||
imageWidth = '120px',
|
||||
href = '/',
|
||||
download = false,
|
||||
fontFamily = 'Inter, sans-serif',
|
||||
fontSize = '20px',
|
||||
fontWeight = '700',
|
||||
@@ -44,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
<a
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
href={href}
|
||||
download={download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
@@ -87,7 +96,13 @@ Logo.craft = {
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: undefined,
|
||||
download: false,
|
||||
style: {},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as LogoProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -122,8 +137,9 @@ Logo.craft = {
|
||||
flexShrink: '0',
|
||||
...props.style,
|
||||
});
|
||||
const downloadAttr = props.download ? ' download' : '';
|
||||
|
||||
return {
|
||||
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,6 +31,29 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('a link with download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||
});
|
||||
|
||||
test('a link without download does not emit the attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||
expect(html).not.toContain(' download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Menu as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
|
||||
@@ -10,6 +10,8 @@ interface MenuLink {
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
@@ -23,6 +25,11 @@ interface MenuProps {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
fontSize?: string;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
@@ -75,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
download={link.download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
@@ -114,6 +122,11 @@ Menu.craft = {
|
||||
orientation: 'horizontal',
|
||||
fontSize: '14px',
|
||||
style: {},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as MenuProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -162,6 +175,7 @@ Menu.craft = {
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
@@ -173,7 +187,7 @@ Menu.craft = {
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
const hoverCss = `<style>
|
||||
|
||||
@@ -83,6 +83,29 @@ describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||
test('a link with download:true emits the download attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||
});
|
||||
|
||||
test('a link without download does not emit the attribute', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||
expect(html).not.toContain(' download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||
const craftProps = (Navbar as any).craft.props;
|
||||
expect(craftProps).toHaveProperty('animation', 'none');
|
||||
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
||||
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||
|
||||
@@ -11,6 +11,8 @@ interface NavLink {
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -33,6 +35,11 @@ interface NavbarProps {
|
||||
isSticky?: boolean;
|
||||
showMobileMenu?: boolean;
|
||||
style?: CSSProperties;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Defaults ---------- */
|
||||
@@ -149,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
href={link.href}
|
||||
target={link.isExternal ? '_blank' : undefined}
|
||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||
download={link.download || undefined}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHoveredLink(i)}
|
||||
onMouseLeave={() => setHoveredLink(null)}
|
||||
@@ -200,6 +208,11 @@ Navbar.craft = {
|
||||
style: {
|
||||
borderBottom: '1px solid #e4e4e7',
|
||||
},
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
} as NavbarProps,
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -308,6 +321,7 @@ Navbar.craft = {
|
||||
// Add CSS class to each link for hover
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
@@ -319,7 +333,7 @@ Navbar.craft = {
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
|
||||
@@ -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());
|
||||
actions.addNodeTree(tree, targetParent);
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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';
|
||||
|
||||
/* NavStylePanel (via useNodeProp/LinkPicker in this file) needs useEditor
|
||||
from @craftjs/core and usePages from PageContext. Mock both following the
|
||||
DOM-harness pattern used across this repo's other *StylePanel tests (no
|
||||
@testing-library/react here) -- PageContext itself is Wave-2's territory,
|
||||
this package only READS pages via usePages(), so mocking it is the
|
||||
correct boundary for these tests. */
|
||||
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||
updater(lastProps);
|
||||
});
|
||||
let lastProps: any;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||
}));
|
||||
|
||||
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
|
||||
vi.mock('../../../state/PageContext', () => ({
|
||||
usePages: () => ({ pages: mockPages }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../ui/AssetPicker', () => ({
|
||||
AssetPicker: () => null,
|
||||
}));
|
||||
|
||||
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
|
||||
|
||||
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 rerender(ui: React.ReactElement) {
|
||||
act(() => { root.render(ui); });
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||||
act(() => {
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function click(el: Element | null) {
|
||||
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPropSpy.mockClear();
|
||||
mockPages = [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
|
||||
{ id: 'about', name: 'About', slug: 'about', craftState: null },
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container) unmount();
|
||||
});
|
||||
|
||||
describe('pageHref (landing page is always "/")', () => {
|
||||
test('index 0 (landing page) -> "/"', () => {
|
||||
expect(pageHref(mockPages[0], 0)).toBe('/');
|
||||
});
|
||||
|
||||
test('any other page -> "/{slug}"', () => {
|
||||
expect(pageHref(mockPages[1], 1)).toBe('/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkPicker (F1: link-to-page picker + manual URL/anchor/tel/mailto)', () => {
|
||||
test('a value matching a page href starts in "Page" mode and lists every page', () => {
|
||||
render(<LinkPicker value="/about" onChange={vi.fn()} />);
|
||||
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
expect(modeSelect.value).toBe('page');
|
||||
const optionText = Array.from(container.querySelectorAll('option')).map((o) => o.textContent);
|
||||
expect(optionText).toContain('Home');
|
||||
expect(optionText).toContain('About');
|
||||
});
|
||||
|
||||
test('picking a different page from the page dropdown emits that page\'s href', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<LinkPicker value="/about" onChange={onChange} />);
|
||||
const [, pageSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
setValue(pageSelect, '/');
|
||||
expect(onChange).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
test('a "#section" value starts in Anchor mode', () => {
|
||||
render(<LinkPicker value="#pricing" onChange={vi.fn()} />);
|
||||
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
expect(modeSelect.value).toBe('anchor');
|
||||
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input.value).toBe('#pricing');
|
||||
});
|
||||
|
||||
test('switching mode to Anchor seeds a bare "#"', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<LinkPicker value="/about" onChange={onChange} />);
|
||||
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
setValue(modeSelect, 'anchor');
|
||||
expect(onChange).toHaveBeenCalledWith('#');
|
||||
});
|
||||
|
||||
test('mailto: helper strips the scheme for editing and re-adds it on change', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<LinkPicker value="mailto:foo@example.com" onChange={onChange} />);
|
||||
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input.value).toBe('foo@example.com');
|
||||
setValue(input, 'bar@example.com');
|
||||
expect(onChange).toHaveBeenCalledWith('mailto:bar@example.com');
|
||||
});
|
||||
|
||||
test('tel: helper strips the scheme for editing and re-adds it on change', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<LinkPicker value="tel:5551234567" onChange={onChange} />);
|
||||
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input.value).toBe('5551234567');
|
||||
setValue(input, '5559876543');
|
||||
expect(onChange).toHaveBeenCalledWith('tel:5559876543');
|
||||
});
|
||||
|
||||
test('a plain https:// URL falls back to Custom URL mode', () => {
|
||||
render(<LinkPicker value="https://example.com" onChange={vi.fn()} />);
|
||||
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
expect(modeSelect.value).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavStylePanel Links section: href set via LinkPicker (F1 wired into the panel)', () => {
|
||||
test('choosing a page for a Navbar link writes that page\'s href onto the link', () => {
|
||||
lastProps = {
|
||||
links: [{ text: 'Home', href: '/old-home' }],
|
||||
};
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
|
||||
const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
|
||||
// First select for the one link item is its LinkPicker mode select (the
|
||||
// href starts as a Custom URL, so it opens on "url" mode); switch it to
|
||||
// "page" then pick the About page from the resulting page dropdown.
|
||||
const modeSelect = selects[0];
|
||||
setValue(modeSelect, 'page');
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastProps.links[0].href).toBe('/'); // defaults to the first page
|
||||
|
||||
// The mock setProp mutates `lastProps` in place rather than triggering a
|
||||
// real Craft.js state update, so force a re-render (passing the same,
|
||||
// now-mutated, object) to get the LinkPicker to reflect its new "page"
|
||||
// mode and render the page <select>.
|
||||
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
|
||||
const pageSelect = Array.from(container.querySelectorAll('select'))[1] as HTMLSelectElement;
|
||||
setValue(pageSelect, '/about');
|
||||
expect(lastProps.links[0].href).toBe('/about');
|
||||
});
|
||||
|
||||
test('toggling the Download checkbox for a link sets download:true', () => {
|
||||
lastProps = { links: [{ text: 'Brochure', href: '/brochure.pdf' }] };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
act(() => { checkbox.click(); });
|
||||
expect(lastProps.links[0].download).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavStylePanel: "Sync links with Pages" (F2, regression vs legacy builder)', () => {
|
||||
test('populates links from the mocked pages list and preserves the CTA link', () => {
|
||||
lastProps = {
|
||||
links: [
|
||||
{ text: 'Old Home', href: '/old' },
|
||||
{ text: 'Old About', href: '/old-about' },
|
||||
{ text: 'Get Started', href: '#signup', isCta: true },
|
||||
],
|
||||
};
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
|
||||
const syncBtn = Array.from(container.querySelectorAll('button'))
|
||||
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
|
||||
expect(syncBtn).toBeTruthy();
|
||||
click(syncBtn);
|
||||
|
||||
expect(lastProps.links).toEqual([
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '/about' },
|
||||
{ text: 'Get Started', href: '#signup', isCta: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('with no CTA link, sync just replaces links 1:1 with the pages list', () => {
|
||||
lastProps = { links: [{ text: 'Stale', href: '/stale' }] };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
|
||||
const syncBtn = Array.from(container.querySelectorAll('button'))
|
||||
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
|
||||
click(syncBtn);
|
||||
|
||||
expect(lastProps.links).toEqual([
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '/about' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Links section (and its Sync button) is not shown for Footer (no `links` prop)', () => {
|
||||
lastProps = { text: '© 2026' };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const syncBtn = Array.from(container.querySelectorAll('button'))
|
||||
.find((b) => b.textContent?.includes('Sync links with Pages'));
|
||||
expect(syncBtn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** The Spacing/Border & Effects/Animation/Visibility CollapsibleSections all
|
||||
* default closed (defaultOpen={false}, matching the pre-existing "Spacing"
|
||||
* section's convention) -- open one by clicking its header button before
|
||||
* asserting on / interacting with its contents. */
|
||||
function openSection(title: string) {
|
||||
const header = Array.from(container.querySelectorAll('button'))
|
||||
.find((b) => b.textContent?.includes(title))!;
|
||||
click(header);
|
||||
}
|
||||
|
||||
describe('NavStylePanel: box-model + animation + visibility controls always present', () => {
|
||||
test('Spacing (Margin/Padding), Border & Effects, Animation, and Visibility sections render for a Navbar', () => {
|
||||
lastProps = {
|
||||
backgroundColor: '#ffffff',
|
||||
style: {},
|
||||
animation: 'none',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
};
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
openSection('Spacing');
|
||||
openSection('Border & Effects');
|
||||
openSection('Animation');
|
||||
openSection('Visibility');
|
||||
expect(container.querySelector('[data-testid="spacing-control"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="border-control"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="animation-control"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="visibility-control"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('checking "Hide on Mobile" writes hideOnMobile:true via setProp', () => {
|
||||
lastProps = { hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
openSection('Visibility');
|
||||
const checkbox = container.querySelector('[data-testid="visibility-hideOnMobile"]') as HTMLInputElement;
|
||||
act(() => {
|
||||
checkbox.click();
|
||||
});
|
||||
expect(lastProps.hideOnMobile).toBe(true);
|
||||
});
|
||||
|
||||
test('picking an entrance animation writes animation via setProp', () => {
|
||||
lastProps = { animation: 'none', animationDelay: '0' };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
openSection('Animation');
|
||||
const fadeInBtn = Array.from(container.querySelectorAll('[data-testid="animation-control"] button'))
|
||||
.find((b) => b.textContent === 'Fade In')!;
|
||||
click(fadeInBtn);
|
||||
expect(lastProps.animation).toBe('fade-in');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
SPACING_PRESETS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -16,13 +17,145 @@ import {
|
||||
smallInputStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { usePages } from '../../../state/PageContext';
|
||||
import { PageData } from '../../../types';
|
||||
|
||||
/* ---------- NAV / MENU / LOGO ---------- */
|
||||
/* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
|
||||
Mirrors the export convention used elsewhere: the first page is always the
|
||||
landing page and publishes to '/', every other page publishes to '/{slug}'.
|
||||
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
|
||||
in renamePage() for why index 0 is special-cased this way. */
|
||||
export function pageHref(page: PageData, index: number): string {
|
||||
return index === 0 ? '/' : `/${page.slug}`;
|
||||
}
|
||||
|
||||
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
|
||||
|
||||
function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
|
||||
const v = value || '';
|
||||
if (pageHrefs.includes(v)) return 'page';
|
||||
if (v.startsWith('#')) return 'anchor';
|
||||
if (v.startsWith('tel:')) return 'tel';
|
||||
if (v.startsWith('mailto:')) return 'mailto';
|
||||
return 'url';
|
||||
}
|
||||
|
||||
/* ---------- LinkPicker ----------
|
||||
Reused for every link-href field in this panel (standalone Logo's href,
|
||||
Navbar's logoUrl, and each Navbar/Menu link item's href): a dropdown of
|
||||
the site's PAGES (read-only via usePages() -- PageContext itself is
|
||||
Wave-2's territory) plus manual URL / #anchor / tel: / mailto: entry.
|
||||
safeUrl (in toHtml) already allows tel:/mailto: schemes, so no export-side
|
||||
change is needed for those. */
|
||||
interface LinkPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
const { pages } = usePages();
|
||||
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
|
||||
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
|
||||
|
||||
const switchMode = (next: LinkMode) => {
|
||||
if (next === mode) return;
|
||||
if (next === 'page') onChange(pageOptions[0]?.href || '/');
|
||||
else if (next === 'anchor') onChange('#');
|
||||
else if (next === 'tel') onChange('tel:');
|
||||
else if (next === 'mailto') onChange('mailto:');
|
||||
else onChange('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={sectionGap} data-testid="link-picker">
|
||||
<label style={labelStyle}>Link</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => switchMode(e.target.value as LinkMode)}
|
||||
style={{ ...inputStyle, marginBottom: 4, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="page">Page</option>
|
||||
<option value="url">Custom URL</option>
|
||||
<option value="anchor">Anchor (#section)</option>
|
||||
<option value="tel">Phone (tel:)</option>
|
||||
<option value="mailto">Email (mailto:)</option>
|
||||
</select>
|
||||
{mode === 'page' && (
|
||||
pageOptions.length > 0 ? (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
|
||||
{pageOptions.map((p) => (
|
||||
<option key={p.id} value={p.href}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: '#71717a' }}>No pages yet</div>
|
||||
)
|
||||
)}
|
||||
{mode === 'anchor' && (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
|
||||
placeholder="#section-id"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
{mode === 'tel' && (
|
||||
<input
|
||||
type="text"
|
||||
value={(value || '').replace(/^tel:/, '')}
|
||||
onChange={(e) => onChange(`tel:${e.target.value}`)}
|
||||
placeholder="+15551234567"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
{mode === 'mailto' && (
|
||||
<input
|
||||
type="text"
|
||||
value={(value || '').replace(/^mailto:/, '')}
|
||||
onChange={(e) => onChange(`mailto:${e.target.value}`)}
|
||||
placeholder="name@example.com"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
{mode === 'url' && (
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://example.com or /page"
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* Parses a `border` shorthand ("2px solid #hex" / "none") back into the
|
||||
{ width, style, color } shape BorderControl edits. */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = v.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
|
||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
const { pages } = usePages();
|
||||
|
||||
const links: any[] = nodeProps.links || [];
|
||||
|
||||
@@ -48,6 +181,20 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* F2: (re)populate the links array from the current pages list --
|
||||
label = page name, href = '/' for the landing page else '/{slug}'.
|
||||
Any existing CTA link (isCta: true) is preserved (appended after the
|
||||
freshly-generated page links) rather than being wiped, matching the
|
||||
legacy GrapesJS builder's "Sync with Pages" behavior. */
|
||||
const syncWithPages = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const existing: any[] = props.links || [];
|
||||
const ctaLinks = existing.filter((l) => l.isCta);
|
||||
const pageLinks = pages.map((p, i) => ({ text: p.name, href: pageHref(p, i) }));
|
||||
props.links = [...pageLinks, ...ctaLinks];
|
||||
});
|
||||
}, [actions, selectedId, pages]);
|
||||
|
||||
/* Detect standalone Logo vs Navbar/Menu */
|
||||
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
||||
|
||||
@@ -66,6 +213,15 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
);
|
||||
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
|
||||
|
||||
/* Box-model / animation / visibility values, read off `style` (margin,
|
||||
padding, border, boxShadow, opacity) or top-level props (animation,
|
||||
hideOn*). This panel is only ever mounted for the 4 owned components
|
||||
(Navbar/Menu/Logo/Footer), which all now carry these props (see each
|
||||
component's .craft.props), so -- unlike the Links/Colors sections above,
|
||||
which are shared across a genuinely disparate prop schema -- these
|
||||
sections render unconditionally rather than gating on presence. */
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Standalone Logo component settings */}
|
||||
@@ -124,10 +280,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
<LinkPicker value={nodeProps.href || '/'} onChange={(v) => setProp('href', v)} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginTop: -8, marginBottom: 12 }}>
|
||||
<input type="checkbox" checked={!!nodeProps.download} onChange={(e) => setProp('download', e.target.checked)} />
|
||||
Download (link points at a file)
|
||||
</label>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
@@ -145,17 +302,22 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.logoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Logo Link URL</label>
|
||||
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
|
||||
</div>
|
||||
<LinkPicker value={nodeProps.logoUrl || '/'} onChange={(v) => setProp('logoUrl', v)} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Links (not shown for standalone Logo) */}
|
||||
{!isStandaloneLogo && (
|
||||
{/* Links (not shown for standalone Logo, or for components -- like
|
||||
Footer -- that don't carry a `links` array at all). */}
|
||||
{!isStandaloneLogo && nodeProps.links !== undefined && (
|
||||
<CollapsibleSection title="Links">
|
||||
<button
|
||||
onClick={syncWithPages}
|
||||
title="Replace these links with one per page (preserves any CTA link)"
|
||||
style={{ marginBottom: 8, width: '100%', padding: '6px', fontSize: 11, background: 'rgba(59,130,246,0.12)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.4)', borderRadius: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||||
>
|
||||
<i className="fa fa-refresh" /> Sync links with Pages
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -165,7 +327,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} />
|
||||
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
|
||||
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
|
||||
Download (points at a file)
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -234,12 +400,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Style overrides */}
|
||||
{/* Box model: margin + padding (per-side, via style.*). The old
|
||||
single "Padding" preset row is folded into the Padding SpacingControl
|
||||
below (still writes to style.padding when linked, matching the
|
||||
previous behavior exactly). */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={(side, v) => setPropStyle(`margin${capitalize(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={(side, v) => setPropStyle(`padding${capitalize(side)}`, v)}
|
||||
presets={SPACING_PRESETS}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Border & Effects: border, box-shadow, opacity */}
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
<SectionLabel>Box Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow || 'none'} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={style.opacity !== undefined ? Math.round(parseFloat(style.opacity) * 100) : 100}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Entrance animation */}
|
||||
<CollapsibleSection title="Animation" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
|
||||
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.animation = v.animation; p.animationDelay = v.animationDelay; })}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Responsive visibility */}
|
||||
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
||||
<VisibilityControl
|
||||
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
|
||||
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.hideOnDesktop = v.hideOnDesktop; p.hideOnTablet = v.hideOnTablet; p.hideOnMobile = v.hideOnMobile; })}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
@@ -133,7 +133,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
const handlePreview = useCallback(() => {
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml, buildAnimationScript }) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
@@ -154,12 +154,39 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
// Compose full page: header + body + footer
|
||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
||||
// Compose full page: header + body + footer. `handlePreview` below
|
||||
// replaces the ENTIRE wrapped-doc `<body>` inner (including the
|
||||
// in-body reveal `<script>` wrapInDocument already emitted) with
|
||||
// this composed string, so the script would otherwise be clobbered
|
||||
// and animated elements would stay hidden forever
|
||||
// ([data-animation]{opacity:0} with no IntersectionObserver to ever
|
||||
// add `.animated`). Re-append the reveal script here -- built from
|
||||
// the SAME composed content it will end up living alongside -- so
|
||||
// it survives the replacement below and fires exactly once.
|
||||
const composedBody =
|
||||
headerHtml + bodyHtml + footerHtml +
|
||||
buildAnimationScript(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 +210,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, buildAnimationScript, ExportOptions } from './html-export';
|
||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
/**
|
||||
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
|
||||
@@ -97,3 +98,396 @@ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FIX: entrance-animation broken in Preview. Root causes (see
|
||||
* .superpowers/sdd/fix-animation-contract.md):
|
||||
* 1. injectAttrs inserted data-attrs before the tag's first `>`, so a void
|
||||
* tag (`<img ... />`) became malformed (`<img ... / data-animation="...">`,
|
||||
* attrs landing AFTER the self-close slash, outside the tag).
|
||||
* 2. wrapInDocument's in-body reveal <script> was destroyed by TopBar's
|
||||
* handlePreview, which replaces the whole <body> inner with a
|
||||
* recomposed header+body+footer string that never carried the script.
|
||||
*/
|
||||
describe('injectAttrs well-formed void-tag attrs (animation fix)', () => {
|
||||
// ImageBlock.toHtml renders `<img src="..." ... />` -- a real void-tag
|
||||
// producer that goes through injectAttrs via renderNode.
|
||||
const imageState = (props: Record<string, unknown>) =>
|
||||
JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'ImageBlock' },
|
||||
isCanvas: false,
|
||||
props: { src: '/uploads/photo.jpg', style: {}, ...props },
|
||||
displayName: 'ImageBlock',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
test('void <img/> gets attrs INSIDE the tag, no " / " sequence before the final >', () => {
|
||||
const { html } = exportBodyHtml(imageState({ animation: 'bounce' }));
|
||||
expect(html).toContain('data-animation="bounce"');
|
||||
// Well-formed: the attribute sits before the self-close slash.
|
||||
expect(html).toMatch(/data-animation="bounce"\s*\/>/);
|
||||
// Malformed shape from the bug: attrs landing after the slash.
|
||||
expect(html).not.toMatch(/\/\s*data-animation="bounce"/);
|
||||
expect(html).not.toContain('/ data-animation');
|
||||
});
|
||||
|
||||
test('non-void tag (Container div) is unaffected -- attrs still inserted before its only >', () => {
|
||||
const state = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { tag: 'div', style: {}, animation: 'fade-in' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).toMatch(/^<div[^>]*data-animation="fade-in"[^>]*>/);
|
||||
expect(html).not.toContain('/>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAnimationScript (animation fix)', () => {
|
||||
test('returns the IntersectionObserver reveal script when body contains data-animation', () => {
|
||||
const body = '<div data-animation="fade-in">Hi</div>';
|
||||
const script = buildAnimationScript(body);
|
||||
expect(script).toContain('<script>');
|
||||
expect(script).toContain('IntersectionObserver');
|
||||
expect(script).toContain("querySelectorAll('[data-animation]')");
|
||||
});
|
||||
|
||||
test('returns empty string when body has no data-animation', () => {
|
||||
expect(buildAnimationScript('<div>Hi</div>')).toBe('');
|
||||
});
|
||||
|
||||
test('reveal script coerces a bare-number delay to a valid CSS time (e.g. "2" -> "2s")', () => {
|
||||
// animationDelay is stored as a plain seconds string ("2"); assigning that raw
|
||||
// to el.style.animationDelay is invalid CSS and no-ops. The script must suffix a
|
||||
// unit onto bare numbers while leaving unit-bearing values ("2s"/"200ms") alone.
|
||||
const script = buildAnimationScript('<div data-animation="fade-in" data-animation-delay="2">Hi</div>');
|
||||
expect(script).toContain("/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay");
|
||||
// guard against regressing to the raw (invalid) assignment
|
||||
expect(script).not.toContain('animationDelay = delay;');
|
||||
// sanity-check the coercion logic itself against representative inputs
|
||||
const coerce = (delay: string) => (/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay);
|
||||
expect(coerce('2')).toBe('2s');
|
||||
expect(coerce('0.5')).toBe('0.5s');
|
||||
expect(coerce('2s')).toBe('2s');
|
||||
expect(coerce('200ms')).toBe('200ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preview body-replacement keeps exactly one reveal script (animation fix)', () => {
|
||||
const animatedState = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { tag: 'div', style: {}, animation: 'fade-in' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
test('wrapped doc alone already contains exactly one script + the CSS + the noscript fallback', () => {
|
||||
const { html } = exportToHtml(animatedState, { title: 'Page' });
|
||||
const scriptCount = (html.match(/IntersectionObserver/g) || []).length;
|
||||
expect(scriptCount).toBe(1);
|
||||
expect(html).toContain('[data-animation]{opacity:0}');
|
||||
expect(html).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
|
||||
});
|
||||
|
||||
test('simulated handlePreview body-replacement: composed body built WITH buildAnimationScript still yields exactly one reveal script and the head CSS survives', () => {
|
||||
// Mirror TopBar.tsx handlePreview: exportToHtml gives the wrapped doc
|
||||
// (head CSS/noscript + its own in-body script); a "composedBody" of
|
||||
// header+body+footer (no script of its own) is what actually replaces
|
||||
// the <body> inner. Without appending buildAnimationScript to
|
||||
// composedBody, the wrapped doc's script would be clobbered and the
|
||||
// element would never reveal.
|
||||
const { html: wrapped } = exportToHtml(animatedState, { title: 'Page' });
|
||||
const headerHtml = '';
|
||||
const { html: bodyHtml } = exportBodyHtml(animatedState);
|
||||
const footerHtml = '';
|
||||
const composedBody =
|
||||
headerHtml + bodyHtml + footerHtml +
|
||||
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
|
||||
|
||||
const bodyMatch = wrapped.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
expect(bodyMatch).toBeTruthy();
|
||||
const finalHtml = wrapped.replace(bodyMatch![1], () => composedBody);
|
||||
|
||||
const scriptCount = (finalHtml.match(/IntersectionObserver/g) || []).length;
|
||||
expect(scriptCount).toBe(1);
|
||||
expect(finalHtml).toContain('[data-animation]{opacity:0}');
|
||||
expect(finalHtml).toContain('data-animation="fade-in"');
|
||||
// No malformed void-tag artifact should leak into the final assembly.
|
||||
expect(finalHtml).not.toContain('/ data-animation');
|
||||
});
|
||||
|
||||
test('non-animated body: no animation CSS, no noscript, no reveal script anywhere', () => {
|
||||
const plainState = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { tag: 'div', style: {} },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
const { html } = exportToHtml(plainState, { title: 'Page' });
|
||||
expect(html).not.toContain('[data-animation]');
|
||||
expect(html).not.toContain('<noscript>');
|
||||
expect(html).not.toContain('IntersectionObserver');
|
||||
expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -35,12 +52,29 @@ function buildDataAttrs(props: Record<string, any>): string {
|
||||
|
||||
/**
|
||||
* Inject data attributes into the first HTML opening tag of a rendered string.
|
||||
*
|
||||
* For a void/self-closing tag (e.g. `<img src="x" />`) the first `>` is
|
||||
* preceded by a `/` -- naively inserting before the `>` produces the
|
||||
* malformed `<img ... / data-animation="...">` (attrs land AFTER the
|
||||
* self-close slash, outside the tag). Detect that trailing `/` and insert
|
||||
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
|
||||
* Non-void tags (no trailing `/`) are unaffected.
|
||||
*/
|
||||
function injectAttrs(html: string, attrs: string): string {
|
||||
if (!attrs) return html;
|
||||
// Find the first > of the opening tag and inject before it
|
||||
const idx = html.indexOf('>');
|
||||
if (idx === -1) return html;
|
||||
if (idx > 0 && html[idx - 1] === '/') {
|
||||
// Void/self-closing tag (`<img ... />`): inserting before `>` would land
|
||||
// the attrs after the `/`, outside the tag (`<img ... / data-x="y">`).
|
||||
// Insert before the `/` instead -- also trim any whitespace directly
|
||||
// preceding it so we don't end up with a double space, since `attrs`
|
||||
// already carries its own leading space(s).
|
||||
let contentEnd = idx - 1;
|
||||
while (contentEnd > 0 && /\s/.test(html[contentEnd - 1])) contentEnd--;
|
||||
return html.slice(0, contentEnd) + attrs + html.slice(idx - 1);
|
||||
}
|
||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||
}
|
||||
|
||||
@@ -138,6 +172,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"],
|
||||
@@ -177,13 +354,29 @@ const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@
|
||||
const ANIMATION_SCRIPT = `<script>
|
||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||
var delay = el.getAttribute('data-animation-delay');
|
||||
if (delay) el.style.animationDelay = delay;
|
||||
if (delay) el.style.animationDelay = /^-?[0-9.]+$/.test(delay) ? delay + 's' : delay;
|
||||
new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
||||
}, { threshold: 0.1 }).observe(el);
|
||||
});
|
||||
</script>`;
|
||||
|
||||
// No-JS safety net (contract "No-JS safety"): un-hides animated elements
|
||||
// when JS is disabled, so `[data-animation]{opacity:0}` never permanently
|
||||
// hides content that the reveal script would otherwise never run for.
|
||||
const ANIMATION_NOSCRIPT = `<noscript><style>[data-animation]{opacity:1}</style></noscript>`;
|
||||
|
||||
/**
|
||||
* Returns the reveal `<script>` (byte-identical to the shared contract, and
|
||||
* to the backend's `generateCompiledHTML` emission) when `bodyHtml` contains
|
||||
* an animated element, else `''`. Single source of the script string so
|
||||
* every caller (wrapInDocument's in-body emission, and TopBar's Preview
|
||||
* body-replacement) stays in sync.
|
||||
*/
|
||||
export function buildAnimationScript(bodyHtml: string): string {
|
||||
return bodyHtml.includes('data-animation') ? ANIMATION_SCRIPT : '';
|
||||
}
|
||||
|
||||
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
const title = options.title || 'Untitled Page';
|
||||
const minify = options.minifyCss !== false;
|
||||
@@ -191,21 +384,33 @@ 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
|
||||
// Only include animation CSS + noscript fallback + script if body contains
|
||||
// data-animation (contract gate). `buildAnimationScript` is the single
|
||||
// source of the reveal-script string -- TopBar's Preview body-replacement
|
||||
// uses the same helper so the two emissions never drift apart.
|
||||
const hasAnimations = bodyHtml.includes('data-animation');
|
||||
const animationBlock = hasAnimations ? animation : '';
|
||||
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
|
||||
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
|
||||
const revealScript = buildAnimationScript(bodyHtml);
|
||||
const animationScript = revealScript ? `\n${revealScript}` : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<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>${animationNoscript}${headCode}
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}${animationScript}
|
||||
|
||||
Reference in New Issue
Block a user