feat(site-builder): per-page SEO meta + favicon + design-token CSS-var wiring + published-output a11y/perf
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user