feat(site-builder): version history modal with preview and restore

Adds a site.history command (WHP mode, only when history_list answers) opening
a modal of saves/publishes by day. Preview opens the snapshot re-exported with
its own design; Restore site / Restore one page load the snapshot as unsaved
state (history cleared, dirty marked) so the user still clicks Save.

Refs #51

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:04:17 -07:00
1 parent 5430f13877
commit a9c8f4d65a
8 files changed
+947

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import { describe, test, expect } from 'vitest';
import {
snapshotToPages, validateSnapshot, planPageRestore, buildSnapshotPreviewHtml, snapshotExtras, snapshotDesign,
} from './history-restore';
import type { PageData } from '../types';
const state = (text: string) => JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['h'], linkedNodes: {}, parent: null },
h: { type: { resolvedName: 'Heading' }, isCanvas: false, props: { text, level: 'h1' }, displayName: 'Heading', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT' },
});
const snap = (over: Record<string, unknown> = {}) => ({
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: state('Old home') },
{ id: 'p2', name: 'About', slug: 'about', craftState: state('Old about'), seo: { metaTitle: 'About us' } },
],
...over,
});
describe('snapshotToPages', () => {
test('maps rows and stringifies object craftState', () => {
const pages = snapshotToPages(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: JSON.parse(state('x')) }] }));
expect(pages).toHaveLength(1);
expect(typeof pages[0].craftState).toBe('string');
expect(pages[0].craftState).toContain('"ROOT"');
});
test.each([[undefined], [null], ['x'], [{}]])('non-array %p gives []', (v) => {
expect(snapshotToPages({ pages_craft_state: v })).toEqual([]);
});
test('absent extras / design are tolerated', () => {
expect(snapshotExtras(snap())).toEqual({});
expect(snapshotDesign(snap())).toBeNull();
expect(snapshotDesign(snap({ head_code: '<x>' }))).toEqual({ headCode: '<x>' });
});
});
describe('validateSnapshot', () => {
test('accepts a healthy snapshot', () => {
expect(validateSnapshot(snap()).ok).toBe(true);
});
test('rejects no pages', () => {
expect(validateSnapshot({ pages_craft_state: [] }).ok).toBe(false);
});
test('rejects a page without ROOT', () => {
const r = validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: '{"x":1}' }] }));
expect(r.ok).toBe(false);
});
test('rejects unparseable page and bad header', () => {
expect(validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: '{nope' }] })).ok).toBe(false);
expect(validateSnapshot(snap({ header_craft_state: '{"a":1}' })).ok).toBe(false);
});
test('null page craftState is allowed (same as load)', () => {
expect(validateSnapshot(snap({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'a', craftState: null }] })).ok).toBe(true);
});
});
describe('planPageRestore', () => {
const current: PageData[] = [
{ id: 'home', name: 'Home', slug: 'index', craftState: 'cur-home' },
{ id: 'p2', name: 'About now', slug: 'about', craftState: 'cur-about' },
];
test('matches by id, keeps identity, replaces content and seo', () => {
const plan = planPageRestore(current, { id: 'p2', name: 'About', slug: 'zzz', craftState: 'snap', seo: { metaTitle: 'T' } });
expect(plan.mode).toBe('id');
expect(plan.targetId).toBe('p2');
expect(plan.pages[1]).toEqual({ id: 'p2', name: 'About now', slug: 'about', craftState: 'snap', seo: { metaTitle: 'T' } });
expect(plan.pages[0]).toBe(current[0]);
});
test('falls back to slug when the id is gone', () => {
const plan = planPageRestore(current, { id: 'old', name: 'About', slug: 'about', craftState: 'snap' });
expect(plan.mode).toBe('slug');
expect(plan.targetId).toBe('p2');
expect(plan.pages).toHaveLength(2);
expect(plan.pages[1].craftState).toBe('snap');
});
test('appends a page that no longer exists', () => {
const plan = planPageRestore(current, { id: 'gone', name: 'Deleted', slug: 'deleted', craftState: 'snap' });
expect(plan.mode).toBe('append');
expect(plan.targetId).toBe('gone');
expect(plan.pages.map((p) => p.id)).toEqual(['home', 'p2', 'gone']);
expect(current).toHaveLength(2);
});
});
describe('buildSnapshotPreviewHtml', () => {
test('contains the snapshot page body, header/footer, and the snapshot design primary', () => {
const html = buildSnapshotPreviewHtml(
snap({
design: { primaryColor: '#123abc' },
header_craft_state: state('Snapshot header'),
footer_craft_state: state('Snapshot footer'),
}),
1,
'https://panel.example',
);
expect(html).toContain('Old about');
expect(html).not.toContain('Old home');
expect(html).toContain('#123abc');
expect(html).toContain('Snapshot header');
expect(html).toContain('Snapshot footer');
expect(html.indexOf('Snapshot header')).toBeLessThan(html.indexOf('Old about'));
expect(html.indexOf('Old about')).toBeLessThan(html.indexOf('Snapshot footer'));
expect(html).toContain('<title>About us</title>');
});
test('absolutises /api/ asset urls', () => {
const withImg = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: ['i'], linkedNodes: {}, parent: null },
i: { type: { resolvedName: 'ImageBlock' }, isCanvas: false, props: { src: '/api/assets-proxy/a.png', alt: 'a' }, displayName: 'ImageBlock', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT' },
});
const html = buildSnapshotPreviewHtml({ pages_craft_state: [{ id: 'a', name: 'A', slug: 'index', craftState: withImg }] }, 0, 'https://panel.example');
expect(html).toContain('src="https://panel.example/api/assets-proxy/a.png"');
});
test('throws for a missing page index', () => {
expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow();
});
});
+165
View File
@@ -0,0 +1,165 @@
import type { PageData, PageSeo } from '../types';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml, exportToHtml, buildAnimationScript } from './html-export';
/**
* Pure helpers for version history (#51). A snapshot is the gunzipped previous
* `project.json`; nothing here touches React or Craft, so restore and preview
* are table-testable. Fields that older snapshots lack (`extras`, `design`,
* header/footer state) are treated as absent.
*/
export interface SnapshotPage {
id: string;
name: string;
slug: string;
craftState: string | null;
seo?: PageSeo;
}
export interface Snapshot {
pages_craft_state?: unknown;
header_craft_state?: unknown;
footer_craft_state?: unknown;
design?: unknown;
head_code?: unknown;
extras?: unknown;
[key: string]: unknown;
}
const asState = (v: unknown): string | null => {
if (typeof v === 'string') return v || null;
if (v && typeof v === 'object') return JSON.stringify(v);
return null;
};
/** The snapshot's pages as editor PageData-shaped rows (craftState always a string or null). */
export function snapshotToPages(snapshot: Snapshot): SnapshotPage[] {
const raw = snapshot?.pages_craft_state;
if (!Array.isArray(raw)) return [];
return raw
.filter((p) => p && typeof p === 'object')
.map((p: any, i) => ({
id: typeof p.id === 'string' && p.id ? p.id : `restored_${i}`,
name: typeof p.name === 'string' ? p.name : `Page ${i + 1}`,
slug: typeof p.slug === 'string' ? p.slug : `page-${i + 1}`,
craftState: asState(p.craftState),
seo: p.seo && typeof p.seo === 'object' ? (p.seo as PageSeo) : undefined,
}));
}
export function snapshotExtras(snapshot: Snapshot): Record<string, unknown> {
const e = snapshot?.extras;
return e && typeof e === 'object' && !Array.isArray(e) ? (e as Record<string, unknown>) : {};
}
export function snapshotDesign(snapshot: Snapshot): Partial<SiteDesign> | null {
if (snapshot?.design && typeof snapshot.design === 'object') return snapshot.design as Partial<SiteDesign>;
if (typeof snapshot?.head_code === 'string') return { headCode: snapshot.head_code };
return null;
}
const hasRoot = (state: string): boolean => {
try {
const parsed = JSON.parse(state);
return !!parsed && typeof parsed === 'object' && !!parsed.ROOT;
} catch {
return false;
}
};
export type SnapshotValidation = { ok: true } | { ok: false; reason: string };
/** Same rule as load(): at least one page, and every stored page state has a ROOT. */
export function validateSnapshot(snapshot: Snapshot): SnapshotValidation {
if (!snapshot || typeof snapshot !== 'object') return { ok: false, reason: 'The version is empty.' };
const pages = snapshotToPages(snapshot);
if (pages.length === 0) return { ok: false, reason: 'The version has no pages.' };
for (const p of pages) {
if (p.craftState && !hasRoot(p.craftState)) return { ok: false, reason: `Page "${p.name}" in this version is unreadable.` };
}
for (const key of ['header_craft_state', 'footer_craft_state'] as const) {
const s = asState(snapshot[key]);
if (s && !hasRoot(s)) return { ok: false, reason: `The ${key === 'header_craft_state' ? 'header' : 'footer'} in this version is unreadable.` };
}
return { ok: true };
}
export interface PageRestorePlan {
pages: PageData[];
/** Id of the page that now holds the snapshot content. */
targetId: string;
mode: 'id' | 'slug' | 'append';
}
/**
* Replace one page with its snapshot version: match by id, else slug, else
* append as a new page. A matched page keeps its identity (id, name, slug) so
* links and the landing-page rule stay put; only content and SEO come back.
*/
export function planPageRestore(currentPages: PageData[], snapshotPage: SnapshotPage): PageRestorePlan {
let idx = currentPages.findIndex((p) => p.id === snapshotPage.id);
let mode: PageRestorePlan['mode'] = 'id';
if (idx === -1) {
idx = currentPages.findIndex((p) => p.slug === snapshotPage.slug);
mode = 'slug';
}
if (idx === -1) {
const added: PageData = { id: snapshotPage.id, name: snapshotPage.name, slug: snapshotPage.slug, craftState: snapshotPage.craftState, seo: snapshotPage.seo };
return { pages: [...currentPages, added], targetId: added.id, mode: 'append' };
}
const pages = currentPages.map((p, i) => (i === idx ? { ...p, craftState: snapshotPage.craftState, seo: snapshotPage.seo } : p));
return { pages, targetId: currentPages[idx].id, mode };
}
const zoneHtml = (state: unknown, zone: 'header' | 'footer'): string => {
const s = asState(state);
if (!s) return '';
try {
return exportBodyHtml(s, zone).html;
} catch {
return '';
}
};
/**
* A full HTML document for one snapshot page, built through the same export
* path as Save and Preview (header + body + footer, snapshot design and SEO),
* with `/api/` asset URLs made absolute so a blob: tab can load them.
*/
export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number, apiOrigin: string): string {
const pages = snapshotToPages(snapshot);
const page = pages[pageIndex];
if (!page) throw new Error('No such page in this version.');
const state = page.craftState ?? '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const bodyHtml = exportBodyHtml(state).html;
const headerHtml = zoneHtml(snapshot.header_craft_state, 'header');
const footerHtml = zoneHtml(snapshot.footer_craft_state, 'footer');
const composed = headerHtml + bodyHtml + footerHtml;
const composedBody = composed + buildAnimationScript(composed);
const design = snapshotDesign(snapshot);
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
const seo = page.seo;
let html = exportToHtml(state, {
title: seo?.metaTitle || page.name || 'Preview',
includeFonts: true,
headCode: fullDesign.headCode,
description: seo?.metaDescription,
ogTitle: seo?.ogTitle,
ogImage: seo?.ogImage,
twitterCard: seo?.twitterCard,
noindex: seo?.noindex,
favicon: fullDesign.favicon,
design: fullDesign,
}).html;
// Function replacer: user content may contain `$&` / `$1`.
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (m) html = html.replace(m[1], () => composedBody);
html = html.replace(/src="\/api\//g, `src="${apiOrigin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${apiOrigin}/api/`);
return html;
}