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:
2026-07-14 07:14:56 -07:00
co-authored by Claude Opus 4.8
parent f7da654c11
commit 0291ddce9a
13 changed files with 1098 additions and 13 deletions
+15 -3
View File
@@ -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,11 @@ interface PageContextValue {
addPage: (name: string, slug: string) => void;
deletePage: (pageId: string) => void;
renamePage: (pageId: string, name: string, slug: 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 +188,7 @@ const PageContext = createContext<PageContextValue>({
addPage: () => {},
deletePage: () => {},
renamePage: () => {},
updatePageSeo: () => {},
setHeaderCraftState: () => {},
setFooterCraftState: () => {},
setPagesCraftState: () => {},
@@ -421,6 +424,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 +452,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 +461,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 +556,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
addPage,
deletePage,
renamePage,
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();
});
});
+4
View File
@@ -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>({