Merge issue-58-fe (Refs #58)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 12:09:09 -07:00
commit edae0d6107
12 files changed
+1017 -13

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import { describe, test, expect } from 'vitest';
import {
EMPTY_PROFILE, normalizeProfile, validateProfile, isProfileComplete, profileToPrompt, profileSummary,
} from './sitesmith-profile';
const good: any = { business_name: 'Acme', business_type: 'Plumber', services: ['Drains', 'Leaks'], area: 'Austin', tone: 'bold' };
describe('sitesmith-profile', () => {
test('EMPTY_PROFILE is incomplete and names every required field', () => {
const v = validateProfile(EMPTY_PROFILE);
expect(v.ok).toBe(false);
expect(Object.keys(v.errors).sort()).toEqual(['business_name', 'business_type', 'services']);
});
test('complete profile validates; area and notes are optional', () => {
expect(isProfileComplete({ ...good, area: '' })).toBe(true);
expect(validateProfile(good).ok).toBe(true);
});
test('whitespace-only answers do not count', () => {
expect(isProfileComplete({ ...good, business_name: ' ' })).toBe(false);
expect(isProfileComplete({ ...good, services: [' ', ''] })).toBe(false);
});
test('normalize drops unknown keys, dedupes/trims services, falls back on a bad tone', () => {
const n = normalizeProfile({ ...good, services: [' Drains ', 'Drains', 7, ''], tone: 'loud', evil: '<x>' })!;
expect(n.services).toEqual(['Drains']);
expect(n.tone).toBe('friendly');
expect(n).not.toHaveProperty('evil');
expect(n).not.toHaveProperty('notes');
expect(normalizeProfile('nope')).toBeNull();
expect(normalizeProfile([])).toBeNull();
});
test('normalized profile stays under the 2000-char backend cap even for huge input', () => {
const big = 'x'.repeat(5000);
const n = normalizeProfile({ business_name: big, business_type: big, services: Array(50).fill(big).map((s, i) => s + i), area: big, tone: 'calm', notes: big })!;
expect(JSON.stringify(n).length).toBeLessThan(2000);
});
test('profileToPrompt carries name, type, services, area and tone', () => {
const line = profileToPrompt(normalizeProfile(good)!);
expect(line).toContain('Acme is a Plumber');
expect(line).toContain('Drains, Leaks');
expect(line).toContain('Austin');
expect(line).toContain('Tone: bold');
});
test('profileSummary skips an empty area', () => {
expect(profileSummary(normalizeProfile({ ...good, area: '' })!)).toBe('Acme · Plumber');
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
import { buildNodeTree } from './craft-tree';
/**
* Business profile gathered by the Sitesmith onboarding wizard. Stored with the
* site (`project.json.extras.sitesmithProfile`) and sent with every Sitesmith
* request so generated copy is written for this business.
*/
export type SitesmithTone = 'friendly' | 'professional' | 'bold' | 'calm';
export interface SitesmithProfile {
business_name: string;
business_type: string;
services: string[];
area: string;
tone: SitesmithTone;
notes?: string;
}
export const PROFILE_EXTRAS_KEY = 'sitesmithProfile';
export const TONES: { id: SitesmithTone; label: string; hint: string }[] = [
{ id: 'friendly', label: 'Friendly', hint: 'Warm and welcoming' },
{ id: 'professional', label: 'Professional', hint: 'Clear and trustworthy' },
{ id: 'bold', label: 'Bold', hint: 'Confident and punchy' },
{ id: 'calm', label: 'Calm', hint: 'Relaxed and reassuring' },
];
export const EMPTY_PROFILE: SitesmithProfile = {
business_name: '',
business_type: '',
services: [],
area: '',
tone: 'friendly',
};
// Per-field caps keep the whole profile well under the backend's 2000-char limit.
const MAX_NAME = 120;
const MAX_TYPE = 120;
const MAX_SERVICES = 10;
const MAX_SERVICE = 80;
const MAX_AREA = 160;
const MAX_NOTES = 400;
const isTone = (v: unknown): v is SitesmithTone => TONES.some((t) => t.id === v);
const str = (v: unknown, max: number): string => (typeof v === 'string' ? v.trim().slice(0, max) : '');
/**
* Coerce anything (hydrated extras, wizard state) into a well-formed profile,
* dropping unknown keys. Returns null when `raw` is not an object.
*/
export function normalizeProfile(raw: unknown): SitesmithProfile | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const r = raw as Record<string, unknown>;
const services = (Array.isArray(r.services) ? r.services : [])
.map((s) => str(s, MAX_SERVICE))
.filter(Boolean)
.filter((s, i, a) => a.indexOf(s) === i)
.slice(0, MAX_SERVICES);
const out: SitesmithProfile = {
business_name: str(r.business_name, MAX_NAME),
business_type: str(r.business_type, MAX_TYPE),
services,
area: str(r.area, MAX_AREA),
tone: isTone(r.tone) ? r.tone : EMPTY_PROFILE.tone,
};
const notes = str(r.notes, MAX_NOTES);
if (notes) out.notes = notes;
return out;
}
export type ProfileField = 'business_name' | 'business_type' | 'services' | 'tone';
/** Required answers. Area and notes are optional (online-only businesses have no area). */
export function validateProfile(p: Partial<SitesmithProfile> | null | undefined): { ok: boolean; errors: Partial<Record<ProfileField, string>> } {
const errors: Partial<Record<ProfileField, string>> = {};
if (!p || !str(p.business_name, MAX_NAME)) errors.business_name = 'Tell us your business name.';
if (!p || !str(p.business_type, MAX_TYPE)) errors.business_type = 'What kind of business is it?';
if (!p || !Array.isArray(p.services) || !p.services.some((s) => str(s, MAX_SERVICE))) errors.services = 'Add at least one service.';
if (!p || !isTone(p.tone)) errors.tone = 'Pick a tone.';
return { ok: Object.keys(errors).length === 0, errors };
}
export function isProfileComplete(p: unknown): p is SitesmithProfile {
const n = normalizeProfile(p);
return !!n && validateProfile(n).ok;
}
/** One short line describing the business; used as a message prefix for a backend that ignores `profile`. */
export function profileToPrompt(p: SitesmithProfile): string {
const parts = [`${p.business_name} is a ${p.business_type}`];
if (p.services.length) parts.push(`offering ${p.services.join(', ')}`);
if (p.area) parts.push(`serving ${p.area}`);
return `[Business: ${parts.join(' ')}. Tone: ${p.tone}.${p.notes ? ` Notes: ${p.notes}` : ''}]`;
}
/** Compact one-liner for the Sitesmith modal header. */
export function profileSummary(p: SitesmithProfile): string {
return [p.business_name, p.business_type, p.area].filter(Boolean).join(' · ');
}
/**
* Replace `targetId` with the section Sitesmith returned (for `intent:
* 'regenerate_section'`), as ONE undo step.
*
* Why not `useApplyAiResponse().apply`: a replace is delete + add, which Craft
* records as two history entries. Here the delete is a normal entry and the add
* goes through `history.merge()`, which folds it into that entry. (`throttle`
* would also merge, but into whatever entry came last inside the window, which
* can be an unrelated edit made a moment earlier.)
*/
export function applyRegeneratedSection(
actions: any,
query: any,
resp: SitesmithResponse,
targetId: string,
): { ok: boolean; message?: string } {
let tree: SerializedTreeNode | undefined;
if (resp.type === 'patch') {
const op = resp.ops.find((o) => o.op === 'replace_node');
tree = op && op.op === 'replace_node' ? op.tree : undefined;
} else if (resp.type === 'replace' && resp.scope === 'section') {
tree = resp.pages[0]?.tree;
} else if (resp.type === 'ask') {
return { ok: false, message: resp.question };
}
if (!tree) return { ok: false, message: 'Sitesmith did not return a new section. Try again.' };
let nodeTree: any;
let parent: string;
let index: number;
try {
nodeTree = buildNodeTree(query, tree);
parent = query.node(targetId).get().data.parent ?? 'ROOT';
index = (query.node(parent).childNodes() as string[]).indexOf(targetId);
} catch (e) {
return { ok: false, message: 'That section no longer exists.' };
}
actions.delete(targetId);
try {
actions.history.merge().addNodeTree(nodeTree, parent, index);
} catch (e) {
actions.history.undo(); // never leave the page with the section deleted
return { ok: false, message: 'Could not place the new section.' };
}
return { ok: true };
}