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:
1 parent
5430f13877
commit
a9c8f4d65a
8 files changed
+947
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user