Merge issue-58-fe (Refs #58)
# Conflicts: # craft/src/editor/CanvasOverlays.tsx # craft/src/styles/editor.css
This commit is contained in:
commit
edae0d6107
12 files changed
+1017
-13
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user