sitesmith: verify uploaded-image references, enforce home hero photo, rotate heroes, low-res guard

New sitesmith/asset-repair.ts, run by apply() before the allow-list cleaner when the editor passes the
site's fresh upload list: unknown assets/ (and assets-proxy) references are swapped for the nearest real
upload or a labelled placeholder; the home hero takes the best landscape upload when it has a placeholder
or no image; a whole-site build does not repeat a hero photo; a photo under 1200px wide is never kept as a
full-bleed hero background. Notes appear in the modal note area.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-02 14:17:52 -08:00
1 parent a20e728824
commit 03569c5120
6 files changed
+678 -5

No files matched your search

+23 -2
View File
@@ -11,6 +11,7 @@ import {
import { applyRegeneratedSection, isProfileComplete, profileSummary } from '../../utils/sitesmith-profile';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { repairAssetUse } from '../../sitesmith/asset-repair';
import { useApplyAiResponse, repairForCanvas, type AppliedCanvas } from '../../utils/apply-ai-response';
import { canvasFingerprint, canvasShows } from '../../utils/canvas-fingerprint';
import { summarizeAdjustments, type ContrastAdjustment } from '../../utils/contrast-repair';
@@ -245,9 +246,28 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
};
// ---- one pipeline for send, resume and confirm
/** The site's real uploads, fresh (an upload or stock import may have happened since the modal opened); null = unknown. */
const freshAssets = async (): Promise<SitesmithAsset[] | null> => {
try {
const r = await ss.listAssets();
if (r.ok) { setAssets(r.assets ?? []); return r.assets ?? []; }
} catch { /* fall through */ }
return assetsLoaded ? assets : null;
};
/** scope=page: is the page being replaced the home page? */
const activeIsHome = (): boolean => {
const list = pageCtx.pages;
const a = list.find((p) => p.id === pageCtx.activePageId);
return !!a && (list[0]?.id === a.id || /^(?:|\/|home|index|index\.html)$/i.test((a.slug ?? '').trim()));
};
const applyResponse = async (resp: SitesmithResponse, review: boolean, addPages = false) => {
const applied = await apply(resp, target?.nodeId, { userText: lastRequestRef.current, ...(addPages ? { addPages } : {}) });
const list = await freshAssets();
const applied = await apply(resp, target?.nodeId, {
userText: lastRequestRef.current, ...(addPages ? { addPages } : {}),
assets: list, usableAssets: useAssetsFlag ? [...includedAssets] : [], homePage: activeIsHome(),
});
if (!applied.ok) { setError(friendly(applied.message || 'Apply failed')); return; }
if (applied.imageNotes?.length) setNotes((prev) => [...prev, ...applied.imageNotes!]);
if (applied.adjustments?.length || applied.notChecked) setAdjust({ list: applied.adjustments ?? [], notChecked: applied.notChecked ?? 0 });
if (review && selfReviewOn && visionOk && features?.self_review !== false && resp.type !== 'ask' && !target && !pageCtx.isEditingGlobal) {
await doSelfReview(applied.canvas);
@@ -321,7 +341,8 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send('Regenerate this section for my business.', canvas, targetPayload, { intent: 'regenerate_section', sitePages: buildSitePagesPayload(pageCtx.pages), design: buildDesignPayload(design), useAssets: useAssetsFlag, assetFilenames: assetFilter });
if (!result.ok) { setError(failureMessage(result)); return; }
const r = applyRegeneratedSection(actions, query, repairForCanvas(result.response, design, query).response, target.nodeId);
const checked = repairAssetUse(result.response, { assets: await freshAssets() }); // round 9: never publish a broken upload reference
const r = applyRegeneratedSection(actions, query, repairForCanvas(checked.resp, design, query).response, target.nodeId);
if (!r.ok) { setError(r.message || 'Apply failed'); return; }
notify({
kind: 'success',
+206
View File
@@ -0,0 +1,206 @@
import { describe, test, expect } from 'vitest';
import { repairAssetUse, nearestAsset, assetFileOf, ownerOptedOutOfHeroPhoto, ownerNamedAnUpload, type RepairAsset } from './asset-repair';
import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
const A = (filename: string, width: number | null, height: number | null, extra: Partial<RepairAsset> = {}): RepairAsset => ({ filename, relUrl: `assets/${filename}`, width, height, ...extra });
const ASSETS = [
A('100_aa_dining-room.jpg', 3000, 2000, { alt: 'Dining room' }),
A('101_aa_kitchen.jpg', 2400, 1600),
A('102_aa_chef.jpg', 1200, 1800),
A('103_aa_patio.jpg', 900, 600),
A('104_aa_logo.svg', 200, 50),
A('105_aa_terrace.jpg', 1600, 1000),
];
const n = (name: string, props: Record<string, unknown> = {}, nodes?: SerializedTreeNode[]): SerializedTreeNode => ({ type: { resolvedName: name }, props, ...(nodes ? { nodes } : {}) });
const page = (name: string, ...sections: SerializedTreeNode[]) => ({ name, tree: n('Container', {}, sections) });
const site = (...pages: ReturnType<typeof page>[]): SitesmithResponse => ({ type: 'replace', scope: 'site', pages, message: 'm' });
const one = (...sections: SerializedTreeNode[]): SitesmithResponse => ({ type: 'replace', scope: 'page', pages: [page('Home', ...sections)], message: 'm' });
const heroImg = (src: string) => n('Section', {}, [n('ColumnLayout', {}, [n('Container', {}, [n('Heading', { text: 'Hi' })]), n('ImageBlock', { src, alt: 'x' })])]);
const heroBg = (bg: string, extra: Record<string, unknown> = {}) => n('BackgroundSection', { bgImage: bg, overlayOpacity: 0.4, ...extra }, [n('Heading', { text: 'Hi' })]);
const pagesOf = (r: SitesmithResponse) => (r as any).pages as ReturnType<typeof page>[];
const run = (resp: SitesmithResponse, extra: Record<string, unknown> = {}) => repairAssetUse(resp, { assets: ASSETS, homePage: true, ...extra });
describe('1. unknown assets/ references are never published broken', () => {
test('a real upload is untouched and the response is returned as is', () => {
const resp = one(heroImg('assets/100_aa_dining-room.jpg'));
const r = run(resp);
expect(r.resp).toBe(resp);
expect(r.notes).toEqual([]);
});
test('an invented name close to a real upload is swapped to it', () => {
const r = run(one(heroImg('assets/dining-room.jpg')));
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/100_aa_dining-room.jpg');
expect(r.notes.join(' ')).toMatch(/dining-room\.jpg -> 100_aa_dining-room\.jpg/);
});
test('a name with nothing near becomes a labelled placeholder and is reported', () => {
const r = run(one(heroImg('assets/team-photo.jpg')), { userText: 'no photo in the hero' });
const src = pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src as string;
expect(src).toMatch(/^https:\/\/placehold\.co\/1200x800\?text=Photo%3A\+team$/);
expect(r.notes[0]).toMatch(/not in your uploads/);
});
test('a bgImage reference gets a 16:9 placeholder; css url() in style is repaired too', () => {
const r = run(one(n('BackgroundSection', { bgImage: 'assets/zzz-missing.png', style: { backgroundImage: 'url(assets/nope-nothing.jpg)' } })), { userText: 'no photo in the hero' });
const p = pagesOf(r.resp)[0].tree.nodes![0].props as any;
expect(p.bgImage).toMatch(/placehold\.co\/1600x900/);
expect(p.style.backgroundImage).toMatch(/^url\(https:\/\/placehold\.co\/1600x900/);
});
test('assets-proxy and origin-qualified URLs of a real upload are normalised; unknown ones are repaired', () => {
const r = run(one(n('Section', {}, [
n('ImageBlock', { src: '/api/assets-proxy/?site_id=4&filename=101_aa_kitchen.jpg' }),
n('ImageBlock', { src: 'https://panel.example:8443/api/assets-proxy/?site_id=4&filename=ghost-image.jpg' }),
n('ImageBlock', { src: './assets/101_aa_kitchen.jpg' }),
])));
const kids = pagesOf(r.resp)[0].tree.nodes![0].nodes!;
expect(kids[0].props.src).toBe('assets/101_aa_kitchen.jpg');
expect(String(kids[1].props.src)).toMatch(/^https:\/\/placehold\.co\//);
expect(kids[2].props.src).toBe('assets/101_aa_kitchen.jpg');
});
test('arrays of images and header/footer trees are checked; patch update_props too; the input is not mutated', () => {
const resp: SitesmithResponse = { type: 'patch', message: 'm', ops: [
{ op: 'update_props', node_id: 'a', props: { src: 'assets/not-there.jpg' } },
{ op: 'insert_after', node_id: 'b', tree: n('Gallery', { images: ['assets/101_aa_kitchen.jpg', 'assets/missing-one.jpg'] }) },
] };
const before = JSON.stringify(resp);
const r = run(resp);
expect(JSON.stringify(resp)).toBe(before);
const ops = (r.resp as any).ops;
expect(ops[0].props.src).toMatch(/placehold\.co/);
expect(ops[1].tree.props.images[0]).toBe('assets/101_aa_kitchen.jpg');
expect(ops[1].tree.props.images[1]).toMatch(/placehold\.co/);
});
test('an empty upload list is meaningful (every assets/ ref is broken); a null list means unverifiable: untouched', () => {
const resp = one(heroImg('assets/anything.jpg'));
expect(pagesOf(repairAssetUse(resp, { assets: [] }).resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toMatch(/placehold/);
expect(repairAssetUse(resp, { assets: null }).resp).toBe(resp);
});
test('placehold.co and non-image props are left alone', () => {
const resp = one(n('ButtonLink', { href: 'assets/menu.pdf' }), n('ImageBlock', { src: 'https://placehold.co/800x600' }));
const r = repairAssetUse(resp, { assets: [] });
expect(pagesOf(r.resp)[0].tree.nodes![0].props.href).toBe('assets/menu.pdf');
expect(pagesOf(r.resp)[0].tree.nodes![1].props.src).toBe('https://placehold.co/800x600');
});
test('nearestAsset / assetFileOf helpers', () => {
expect(nearestAsset('kitchen.png', ASSETS)?.filename).toBe('101_aa_kitchen.jpg');
expect(nearestAsset('IMG_9999.jpg', ASSETS)).toBeNull();
expect(nearestAsset('photo.jpg', ASSETS)).toBeNull();
expect(assetFileOf('assets/a%20b.jpg?x=1')).toBe('a b.jpg');
expect(assetFileOf('https://placehold.co/10x10')).toBeNull();
});
});
describe('2. home hero enforcement and hero rotation', () => {
test('placeholder ImageBlock in the home hero -> the largest landscape upload', () => {
const r = run(one(heroImg('https://placehold.co/800x600')));
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/100_aa_dining-room.jpg');
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.alt).toBe('x');
expect(r.notes.join(' ')).toMatch(/home page hero now shows your photo "dining-room\.jpg"/);
});
test('placeholder bg in BackgroundSection -> upload plus an overlay of at least 0.5', () => {
const r = run(one(heroBg('https://placehold.co/1600x900', { overlayOpacity: 0.1 })));
const p = pagesOf(r.resp)[0].tree.nodes![0].props as any;
expect(p.bgImage).toBe('assets/100_aa_dining-room.jpg');
expect(p.overlayOpacity).toBe(0.5);
expect(p.overlayColor).toBe('#000000');
});
test('HeroSimple on a gradient (no image) becomes an image hero with light text', () => {
const r = run(one(n('HeroSimple', { bgType: 'gradient', textColor: '#111111', overlayOpacity: 0 })));
const p = pagesOf(r.resp)[0].tree.nodes![0].props as any;
expect(p).toMatchObject({ bgType: 'image', bgImage: 'assets/100_aa_dining-room.jpg', textColor: '#ffffff' });
expect(p.overlayOpacity).toBe(0.5);
});
test('a hero that already shows an upload is kept', () => {
const r = run(one(heroImg('assets/102_aa_chef.jpg')));
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/102_aa_chef.jpg');
});
test('a text-only Section hero has no slot: nothing is forced, the owner is told', () => {
const resp = one(n('Section', {}, [n('Heading', { text: 'Hi' })]));
const r = run(resp);
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes!.length).toBe(1);
expect(r.notes.join(' ')).toMatch(/no photo slot/);
});
test('only the logo uploaded: no substitution', () => {
const r = repairAssetUse(one(heroImg('https://placehold.co/800x600')), { assets: [A('l_logo.svg', 200, 50)] });
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('https://placehold.co/800x600');
});
test('owner opt-out or naming a file stands the rules down', () => {
for (const userText of ['build my home page with no photo in the hero', 'a gradient hero please', 'use dining-room as the hero on the about page']) {
const resp = one(heroImg('https://placehold.co/800x600'));
const r = run(resp, { userText });
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src, userText).toBe('https://placehold.co/800x600');
}
expect(ownerOptedOutOfHeroPhoto('build my bakery website')).toBe(false);
expect(ownerNamedAnUpload('use my photos', ASSETS)).toBe(false);
});
test('substitution respects the owner unticking an upload', () => {
const r = run(one(heroImg('https://placehold.co/800x600')), { usable: ['101_aa_kitchen.jpg', '105_aa_terrace.jpg'] });
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/101_aa_kitchen.jpg');
});
test('scope=page replaces only enforce on the home page (homePage flag)', () => {
const resp = one(heroImg('https://placehold.co/800x600'));
expect(pagesOf(run(resp, { homePage: false }).resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('https://placehold.co/800x600');
expect(pagesOf(run(resp, { homePage: true }).resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/100_aa_dining-room.jpg');
});
test('whole-site build: later pages never repeat the home hero photo while unused uploads remain', () => {
const r = run(site(
page('Home', heroImg('assets/100_aa_dining-room.jpg')),
page('About', heroImg('assets/100_aa_dining-room.jpg')),
page('Menu', heroImg('assets/100_aa_dining-room.jpg')),
));
const srcs = pagesOf(r.resp).map((p) => p.tree.nodes![0].nodes![0].nodes![1].props.src);
expect(srcs[0]).toBe('assets/100_aa_dining-room.jpg');
expect(new Set(srcs).size).toBe(3);
expect(srcs.every((s) => String(s).startsWith('assets/'))).toBe(true);
expect(srcs).not.toContain('assets/104_aa_logo.svg');
});
test('rotation does not add a photo to a page whose hero has none, and stops when uploads run out', () => {
const two = [A('a.jpg', 2000, 1200), A('b.jpg', 2000, 1100)];
const r = repairAssetUse(site(page('Home', heroImg('assets/a.jpg')), page('About', heroImg('assets/a.jpg')), page('Contact', heroImg('assets/a.jpg'))), { assets: two });
const srcs = pagesOf(r.resp).map((p) => p.tree.nodes![0].nodes![0].nodes![1].props.src);
expect(srcs.slice(0, 2)).toEqual(['assets/a.jpg', 'assets/b.jpg']);
expect(srcs[2]).toBe('assets/a.jpg');
});
test('a photo already used elsewhere on the page is not picked for the hero', () => {
const resp = one(heroImg('https://placehold.co/800x600'), n('Section', {}, [n('ImageBlock', { src: 'assets/100_aa_dining-room.jpg' })]));
const r = run(resp);
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/101_aa_kitchen.jpg');
});
test('section replaces, patches and asks get no hero rules', () => {
const sec: SitesmithResponse = { type: 'replace', scope: 'section', pages: [page('Home', heroImg('https://placehold.co/800x600'))], message: 'm' };
expect(run(sec).resp).toBe(sec);
const ask: SitesmithResponse = { type: 'ask', question: 'q' };
expect(run(ask).resp).toBe(ask);
});
});
describe('4. low-resolution guard', () => {
test('a 900px upload as a full-bleed bg is swapped for a better one', () => {
const r = run(one(heroBg('assets/103_aa_patio.jpg')));
expect((pagesOf(r.resp)[0].tree.nodes![0].props as any).bgImage).toBe('assets/100_aa_dining-room.jpg');
expect(r.notes.join(' ')).toMatch(/only 900px wide/);
});
test('no better upload: BackgroundSection drops to colour and the photo is framed under the text', () => {
const r = repairAssetUse(one(heroBg('assets/103_aa_patio.jpg', { overlayOpacity: 0.6 })), { assets: [A('103_aa_patio.jpg', 900, 600), A('104_aa_logo.svg', 200, 50)] });
const s = pagesOf(r.resp)[0].tree.nodes![0];
expect((s.props as any).bgImage).toBe('');
expect((s.props as any).overlayOpacity).toBeLessThanOrEqual(0.2);
const img = s.nodes!.find((x) => x.type.resolvedName === 'ImageBlock')!;
expect(img.props.src).toBe('assets/103_aa_patio.jpg');
expect((img.props.style as any).maxWidth).toBe('640px');
expect(r.notes.join(' ')).toMatch(/shown framed under the text/);
});
test('HeroSimple with a low-res bg falls back to its gradient', () => {
const r = repairAssetUse(one(n('HeroSimple', { bgType: 'image', bgImage: 'assets/103_aa_patio.jpg' })), { assets: [A('103_aa_patio.jpg', 900, 600)] });
expect(pagesOf(r.resp)[0].tree.nodes![0].props).toMatchObject({ bgType: 'gradient', bgImage: '' });
});
test('a framed ImageBlock of a small photo is fine, and unknown dimensions are not guessed', () => {
const resp = one(heroImg('assets/103_aa_patio.jpg'), heroBg('assets/nodims.jpg'));
const r = repairAssetUse(resp, { assets: [A('103_aa_patio.jpg', 900, 600), A('nodims.jpg', null, null)] });
expect(pagesOf(r.resp)[0].tree.nodes![0].nodes![0].nodes![1].props.src).toBe('assets/103_aa_patio.jpg');
expect((pagesOf(r.resp)[0].tree.nodes![1].props as any).bgImage).toBe('assets/nodims.jpg');
});
test('the guard applies to non-home pages too, and a named upload suspends it', () => {
const resp = site(page('Home', heroImg('assets/100_aa_dining-room.jpg')), page('About', heroBg('assets/103_aa_patio.jpg')));
expect((pagesOf(run(resp).resp)[1].tree.nodes![0].props as any).bgImage).not.toBe('assets/103_aa_patio.jpg');
expect((pagesOf(run(resp, { userText: 'use patio as the about banner' }).resp)[1].tree.nodes![0].props as any).bgImage).toBe('assets/103_aa_patio.jpg');
});
});
+383
View File
@@ -0,0 +1,383 @@
/**
* Image-use repair for Sitesmith responses (round 9). Pure functions, no model call; run in the apply path
* (utils/apply-ai-response.ts) BEFORE the allow-list cleaner, with the site's real upload list.
*
* 1. verifyAssetRefs: every `assets/<file>` (and assets-proxy / origin-qualified equivalent) in an image prop or a
* CSS url() that is NOT a real upload is replaced by the nearest-named real upload, else by a labelled
* placehold.co placeholder (the cleaner makes background placeholders label-free). A proxy URL of a real
* upload is normalised to `assets/<file>`. Nothing broken is published; what was repaired is reported.
* 2. enforceHeroes (page/site replaces only): the home hero shows the best landscape upload when it has a
* placeholder or no image; on a whole-site build a later page's hero never repeats an earlier page's hero
* photo while unused uploads remain.
* 3. Low-resolution guard: a photo under LOW_RES_WIDTH wide is never kept as a full-bleed hero background:
* swapped for a better upload, else downgraded (BackgroundSection: colour + the photo framed under the text;
* HeroSimple: gradient).
* The owner can opt out in words ("no photo in the hero") or direct it by naming a file; then the hero rules stand down.
*/
import type { SerializedTreeNode, SitesmithResponse } from '../types/sitesmith';
import { IMAGE_KEYS } from './clean-ai-response';
export interface RepairAsset {
filename: string;
relUrl?: string;
width?: number | null;
height?: number | null;
caption?: string | null;
alt?: string | null;
}
export interface RepairOptions {
/** Every real upload of the site (the fresh list). */
assets: RepairAsset[];
/** Filenames the owner left ticked; substitutions only pick from these. Absent = all. */
usable?: string[];
/** The owner's request text (opt-out / direction detection). */
userText?: string | null;
/** scope=page: the page being replaced is the home page. A site build finds its own home page. */
homePage?: boolean;
}
export interface RepairResult { resp: SitesmithResponse; notes: string[] }
export const LOW_RES_WIDTH = 1200;
const HERO_WIDTH = 1200;
const BG_KEYS = new Set(['bgImage', 'backgroundImageUrl', 'bgValue', 'imageSrc']);
const ASSET_REF = /(?:^|[^A-Za-z0-9_-])assets\/[^\s"'()<>]+|assets-proxy/i;
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
export const relOf = (a: RepairAsset) => a.relUrl || `assets/${a.filename}`;
export const displayName = (a: RepairAsset | string) => (typeof a === 'string' ? a : a.filename).replace(/^\d+_[a-f0-9]+_/, '');
const w = (a: RepairAsset) => Number(a.width) || 0;
const h = (a: RepairAsset) => Number(a.height) || 0;
/** Same idea as the server's role hint (sitesmith_asset_traits): SVG, a "logo" name, or a tiny image. */
export function isLogoLike(a: RepairAsset): boolean {
const name = a.filename.toLowerCase();
const edge = Math.max(w(a), h(a));
return name.endsWith('.svg') || (/(^|[^a-z])(logo|icon|favicon|wordmark|badge)([^a-z]|$)/.test(name) && (edge === 0 || edge <= 900)) || (edge > 0 && edge <= 500);
}
export const isLandscape = (a: RepairAsset) => w(a) > 0 && h(a) > 0 && w(a) / h(a) > 1.15;
export const isHeroCapable = (a: RepairAsset) => !isLogoLike(a) && isLandscape(a) && w(a) >= HERO_WIDTH;
// ---------------------------------------------------------------- name matching
const STOP = new Set(['image', 'img', 'photo', 'picture', 'pic', 'jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'assets', 'asset', 'file']);
function tokens(name: string): string[] {
const base = (name.split('/').pop() ?? '').replace(/\.[A-Za-z0-9]{2,5}$/, '').replace(/^\d+_[a-f0-9]+_/, '').toLowerCase();
return base.split(/[^a-z0-9]+/).filter((t) => t.length >= 2 && !STOP.has(t));
}
function lev(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)] as number[]);
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
}
return dp[a.length][b.length];
}
/** The real upload whose name best matches a name the model invented, or null when nothing is close. */
export function nearestAsset(missing: string, assets: RepairAsset[]): RepairAsset | null {
const mt = tokens(missing);
if (!mt.length) return null;
const mj = mt.join('');
let best: { a: RepairAsset; s: number } | null = null;
for (const a of assets) {
const at = tokens(a.filename);
if (!at.length) continue;
const subset = mt.every((t) => at.includes(t)) && mt.some((t) => t.length >= 3);
const sim = 1 - lev(mj, at.join('')) / Math.max(mj.length, at.join('').length);
const s = subset ? Math.max(0.8, sim) : sim;
if (s >= 0.6 && (!best || s > best.s)) best = { a, s };
}
return best?.a ?? null;
}
// ---------------------------------------------------------------- reference parsing
/** The upload file name an asset-looking URL points at, or null when the string is not an asset reference. */
export function assetFileOf(v: string): string | null {
if (!ASSET_REF.test(v)) return null;
try {
if (/assets-proxy/i.test(v)) {
const q = v.includes('?') ? v.slice(v.indexOf('?') + 1) : '';
const f = new URLSearchParams(q).get('filename');
return f ? f : '';
}
const m = /(?:^|[^A-Za-z0-9_-])assets\/([^?#)"']+)/i.exec(v);
if (!m) return null;
let f = m[1];
try { f = decodeURIComponent(f); } catch { /* keep raw */ }
return f;
} catch { return null; }
}
function placeholderFor(missing: string, bg: boolean): string {
const label = tokens(missing).join(' ') || 'photo';
const size = bg ? '1600x900' : '1200x800';
return `https://placehold.co/${size}?text=${encodeURIComponent('Photo: ' + label).replace(/%20/g, '+')}`;
}
export interface VerifyReport { renamed: Array<{ from: string; to: string }>; placeholdered: string[]; normalised: number }
function mapRef(value: string, key: string, assets: RepairAsset[], byName: Map<string, RepairAsset>, lower: Map<string, RepairAsset>, rep: VerifyReport): string {
const file = assetFileOf(value);
if (file === null) return value;
const real = byName.get(file) ?? lower.get(file.toLowerCase());
if (real) {
const rel = relOf(real);
if (rel !== value) rep.normalised++;
return rel;
}
const near = file ? nearestAsset(file, assets) : null;
if (near) { rep.renamed.push({ from: file, to: near.filename }); return relOf(near); }
rep.placeholdered.push(file || value);
return placeholderFor(file, BG_KEYS.has(key));
}
function verifyNode(props: Record<string, any>, ctx: { assets: RepairAsset[]; byName: Map<string, RepairAsset>; lower: Map<string, RepairAsset>; rep: VerifyReport }, depth = 0): void {
if (depth > 8 || !props || typeof props !== 'object') return;
const fix = (v: string, k: string) => mapRef(v, k, ctx.assets, ctx.byName, ctx.lower, ctx.rep);
for (const [k, v] of Object.entries(props)) {
if (typeof v === 'string') {
if (IMAGE_KEYS.has(k) || k === 'bgValue') { props[k] = fix(v, k); continue; }
if (/url\(/i.test(v) && ASSET_REF.test(v)) {
props[k] = v.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (m, _q, inner: string) => (ASSET_REF.test(inner) ? `url(${fix(inner, 'bgImage')})` : m));
}
} else if (Array.isArray(v)) {
for (let i = 0; i < v.length; i++) {
if (typeof v[i] === 'string' && IMAGE_KEYS.has(k)) v[i] = fix(v[i], k);
else if (v[i] && typeof v[i] === 'object') verifyNode(v[i], ctx, depth + 1);
}
} else if (v && typeof v === 'object') verifyNode(v, ctx, depth + 1);
}
}
function eachTree(resp: SitesmithResponse, fn: (t: SerializedTreeNode) => void): void {
const walk = (t: SerializedTreeNode | undefined) => { if (!t) return; fn(t); for (const k of t.nodes ?? []) walk(k); };
if (resp.type === 'replace') {
for (const p of resp.pages) walk(p.tree);
if (resp.header) walk(resp.header.tree);
if (resp.footer) walk(resp.footer.tree);
} else if (resp.type === 'patch') {
for (const op of resp.ops ?? []) {
if (op.op === 'update_props') fn({ type: { resolvedName: '' }, props: (op as any).props ??= {} } as SerializedTreeNode);
else if ('tree' in op) walk(op.tree);
}
}
}
/** Step 1. `resp` is mutated (callers pass a clone). */
export function verifyAssetRefs(resp: SitesmithResponse, assets: RepairAsset[]): VerifyReport {
const rep: VerifyReport = { renamed: [], placeholdered: [], normalised: 0 };
const byName = new Map(assets.map((a) => [a.filename, a]));
const lower = new Map(assets.map((a) => [a.filename.toLowerCase(), a]));
eachTree(resp, (n) => verifyNode(n.props as any, { assets, byName, lower, rep }));
return rep;
}
// ---------------------------------------------------------------- hero rules
const NEGATIVE_HERO = [
/\b(?:no|without|skip|avoid|don'?t\s+(?:use|want|put|add|need))\s+(?:any\s+|a\s+|the\s+|my\s+|our\s+)?(?:\w+\s+)?(?:photos?|images?|pictures?|pics?)\b/i,
/\b(?:gradient|solid|plain|text[-\s]?only|type[-\s]?led|typographic|minimal(?:ist)?)\s+(?:hero|header|banner)\b/i,
/\bhero\b[^.]{0,40}\b(?:without|no)\s+(?:a\s+)?(?:photo|image|picture)\b/i,
];
/** True when the owner told Sitesmith not to put a photo in the hero. */
export function ownerOptedOutOfHeroPhoto(text: string | null | undefined): boolean {
return !!text && NEGATIVE_HERO.some((re) => re.test(text));
}
/** True when the request names one of the uploads (the owner is directing image choice). */
export function ownerNamedAnUpload(text: string | null | undefined, assets: RepairAsset[]): boolean {
if (!text) return false;
const t = text.toLowerCase();
return assets.some((a) => {
const base = displayName(a).replace(/\.[A-Za-z0-9]{2,5}$/, '').toLowerCase();
return base.length >= 4 && t.includes(base);
});
}
const BG_HERO = new Set(['HeroSimple', 'BackgroundSection']);
type Slot =
| { kind: 'bg'; node: SerializedTreeNode; value: string }
| { kind: 'img'; node: SerializedTreeNode; value: string }
| { kind: 'bg-empty'; node: SerializedTreeNode; value: '' };
function heroOf(tree: SerializedTreeNode | undefined): SerializedTreeNode | null {
if (!tree) return null;
if (BG_HERO.has(tree.type?.resolvedName)) return tree;
return tree.nodes?.[0] ?? null;
}
function findSlot(hero: SerializedTreeNode): Slot | null {
const name = hero.type?.resolvedName;
if (BG_HERO.has(name)) {
const v = typeof hero.props?.bgImage === 'string' ? hero.props.bgImage : '';
const imageMode = name === 'BackgroundSection' || hero.props?.bgType === 'image';
if (v && imageMode) return { kind: 'bg', node: hero, value: v };
}
const walk = (n: SerializedTreeNode): Slot | null => {
if (n.type?.resolvedName === 'ImageBlock') return { kind: 'img', node: n, value: typeof n.props?.src === 'string' ? n.props.src : '' };
for (const k of n.nodes ?? []) { const s = walk(k); if (s) return s; }
return null;
};
for (const k of hero.nodes ?? []) { const s = walk(k); if (s) return s; }
if (BG_HERO.has(name)) return { kind: 'bg-empty', node: hero, value: '' };
return null;
}
function refsOnPage(tree: SerializedTreeNode): Set<string> {
const out = new Set<string>();
const scan = (v: unknown, d = 0) => {
if (d > 8) return;
if (typeof v === 'string') { const f = assetFileOf(v); if (f) out.add(`assets/${f}`); }
else if (Array.isArray(v)) v.forEach((x) => scan(x, d + 1));
else if (v && typeof v === 'object') Object.values(v).forEach((x) => scan(x, d + 1));
};
const walk = (n: SerializedTreeNode) => { scan(n.props); (n.nodes ?? []).forEach(walk); };
walk(tree);
return out;
}
const HOME_NAME = /^(?:home|homepage|home page|index|main|welcome|start)$/i;
function homeIndex(resp: Extract<SitesmithResponse, { type: 'replace' }>, opts: RepairOptions): number {
if (resp.scope === 'page') return opts.homePage ? 0 : -1;
const i = resp.pages.findIndex((p) => HOME_NAME.test((p.name ?? '').trim()));
return i >= 0 ? i : 0;
}
function applyOverlay(props: Record<string, any>) {
if (!props.overlayColor) props.overlayColor = '#000000';
props.overlayOpacity = Math.max(Number(props.overlayOpacity) || 0, 0.5);
}
function setSlot(slot: Slot, a: RepairAsset, wasNoPhoto: boolean) {
const rel = relOf(a);
const p = slot.node.props as Record<string, any>;
if (slot.kind === 'img') {
p.src = rel;
if (a.alt && (!p.alt || /placeholder|photo here/i.test(String(p.alt)))) p.alt = a.alt;
return;
}
p.bgImage = rel;
if (slot.node.type.resolvedName === 'HeroSimple') {
p.bgType = 'image';
if (wasNoPhoto) p.textColor = '#ffffff';
}
applyOverlay(p);
}
/** Best photo for a slot: largest hero-capable landscape; a framed image may also take any non-logo photo >= 600px. */
function pickFor(kind: Slot['kind'], pool: RepairAsset[], avoid: Set<string>): RepairAsset | null {
const free = pool.filter((a) => !avoid.has(relOf(a)) && !isLogoLike(a));
const px = (a: RepairAsset) => w(a) * h(a);
const by = (x: RepairAsset, y: RepairAsset) => px(y) - px(x) || x.filename.localeCompare(y.filename);
const capable = free.filter(isHeroCapable).sort(by);
if (capable.length || kind !== 'img') return capable[0] ?? null;
return free.filter((a) => w(a) >= 600).sort(by)[0] ?? null;
}
function downgradeLowRes(slot: Extract<Slot, { kind: 'bg' }>, a: RepairAsset): string {
const p = slot.node.props as Record<string, any>;
const rel = relOf(a);
p.bgImage = '';
if (slot.node.type.resolvedName === 'HeroSimple') {
p.bgType = 'gradient';
return `The hero photo "${displayName(a)}" is only ${w(a)}px wide, too small to stretch behind the hero, so the hero uses colour instead. Add it in a framed image block if you want it on the page.`;
}
// BackgroundSection: colour band + the photo framed under the text
p.overlayOpacity = Math.min(Number(p.overlayOpacity) || 0, 0.2);
slot.node.nodes = [...(slot.node.nodes ?? []), {
type: { resolvedName: 'ImageBlock' },
props: { src: rel, alt: a.alt || a.caption || displayName(a), style: { width: '100%', maxWidth: '640px', aspectRatio: '16 / 9', objectFit: 'cover', borderRadius: '12px', marginTop: '32px', marginLeft: 'auto', marginRight: 'auto', display: 'block' } },
}];
return `The hero photo "${displayName(a)}" is only ${w(a)}px wide, too small to stretch behind the hero, so it is shown framed under the text on a colour background.`;
}
export function enforceHeroes(resp: SitesmithResponse, opts: RepairOptions): string[] {
const notes: string[] = [];
if (resp.type !== 'replace' || (resp.scope !== 'page' && resp.scope !== 'site')) return notes;
const byRel = new Map(opts.assets.map((a) => [relOf(a), a]));
const allow = opts.usable ? new Set(opts.usable) : null;
const pool = opts.assets.filter((a) => !allow || allow.has(a.filename));
const directed = ownerNamedAnUpload(opts.userText, opts.assets);
const optOut = ownerOptedOutOfHeroPhoto(opts.userText);
const home = homeIndex(resp, opts);
const heroUsed = new Set<string>();
const order = resp.pages.map((_, i) => i).sort((x, y) => (x === home ? -1 : y === home ? 1 : x - y));
for (const i of order) {
const page = resp.pages[i];
const hero = heroOf(page.tree);
if (!hero) continue;
let slot = findSlot(hero);
const onPage = refsOnPage(page.tree);
const label = i === home ? 'home page' : `"${page.name}" page`;
const current = slot && slot.value ? byRel.get(slot.value) ?? null : null; // the upload the slot shows now
const avoid = new Set<string>([...heroUsed, ...onPage]);
if (slot && !directed) {
if (i === home && !optOut && !current) {
// placeholder / foreign / no image -> best landscape upload
const pick = pickFor(slot.kind, pool.filter((a) => w(a) > 0), new Set([...onPage]));
if (pick) {
setSlot(slot, pick, slot.kind === 'bg-empty' || slot.value === '');
notes.push(`The ${label} hero now shows your photo "${displayName(pick)}" instead of a placeholder.`);
slot = findSlot(hero)!;
}
} else if (i !== home && current && heroUsed.has(relOf(current))) {
const pick = pickFor(slot.kind, pool.filter((a) => w(a) > 0), avoid);
if (pick) {
setSlot(slot, pick, false);
notes.push(`The ${label} hero uses "${displayName(pick)}" so it differs from the hero on another page.`);
slot = findSlot(hero)!;
}
}
}
// low-resolution guard on whatever the hero background now is
if (slot && slot.kind === 'bg' && !directed) {
const a = byRel.get(slot.value);
if (a && w(a) > 0 && w(a) < LOW_RES_WIDTH) {
const better = pickFor('bg', pool, avoid);
if (better) {
setSlot(slot, better, false);
notes.push(`The ${label} hero background "${displayName(a)}" is only ${w(a)}px wide, so "${displayName(better)}" is used instead.`);
} else notes.push(`The ${label}: ` + downgradeLowRes(slot, a));
}
}
const fin = findSlot(hero);
if (fin?.value && byRel.has(fin.value)) heroUsed.add(fin.value);
if (i === home && !directed && !optOut && fin && fin.kind !== 'bg-empty' && !byRel.has(fin.value) && !fin.value.startsWith('assets/') && pool.some((a) => !isLogoLike(a))) {
notes.push(`The ${label} hero still has no photo of yours; ask Sitesmith to "use my photos in the hero".`);
} else if (i === home && !directed && !optOut && !fin && pool.some((a) => !isLogoLike(a))) {
notes.push(`The ${label} hero has no photo slot; ask Sitesmith to "use my photos in the hero".`);
}
}
return notes;
}
// ---------------------------------------------------------------- entry point
function verifyNote(rep: VerifyReport): string | null {
const bits: string[] = [];
if (rep.renamed.length) bits.push(`${rep.renamed.length} swapped for the closest of your uploads (${rep.renamed.slice(0, 3).map((r) => `${r.from} -> ${r.to}`).join(', ')})`);
if (rep.placeholdered.length) bits.push(`${rep.placeholdered.length} replaced with a placeholder (${rep.placeholdered.slice(0, 3).join(', ')})`);
if (!bits.length) return null;
const n = rep.renamed.length + rep.placeholdered.length;
return `Sitesmith referred to ${n} image${n === 1 ? '' : 's'} that ${n === 1 ? 'is' : 'are'} not in your uploads, so ${n === 1 ? 'it was' : 'they were'} fixed before publishing: ${bits.join('; ')}.`;
}
/**
* Verify image references and apply the hero rules. `assets` must be the site's real, fresh upload list
* (an empty list is meaningful: no uploads exist, so any `assets/` reference is broken); pass `null` when the
* list could not be loaded and nothing can be checked. The input response is never mutated.
*/
export function repairAssetUse(resp: SitesmithResponse, opts: Omit<RepairOptions, 'assets'> & { assets: RepairAsset[] | null }): RepairResult {
if (!opts.assets || resp.type === 'ask') return { resp, notes: [] };
const out = clone(resp);
const o = opts as RepairOptions;
const rep = verifyAssetRefs(out, o.assets);
const notes: string[] = [];
const vn = verifyNote(rep);
if (vn) notes.push(vn);
if (o.assets.length) notes.push(...enforceHeroes(out, o));
const changed = rep.renamed.length || rep.placeholdered.length || rep.normalised || notes.length;
return { resp: changed ? out : resp, notes };
}
+1 -1
View File
@@ -41,7 +41,7 @@ export const AI_FORBIDDEN_PROPS: ReadonlySet<string> = new Set([
'headCode', 'customHead', 'customHeadCode', 'script', 'scripts', 'dangerouslySetInnerHTML',
]);
const IMAGE_KEYS = new Set(['src', 'image', 'imageSrc', 'imageUrl', 'logoImage', 'poster', 'bgImage', 'backgroundImageUrl', 'avatar', 'avatarUrl', 'photo', 'thumbnail', 'images']);
export const IMAGE_KEYS = new Set(['src', 'image', 'imageSrc', 'imageUrl', 'logoImage', 'poster', 'bgImage', 'backgroundImageUrl', 'avatar', 'avatarUrl', 'photo', 'thumbnail', 'images']);
const VIDEO_KEYS = new Set(['videoUrl', 'bgVideo', 'embedUrl']);
const LINK_KEYS = new Set(['href', 'link', 'url', 'logoUrl', 'buttonHref', 'buttonUrl', 'secondaryButtonHref', 'allReviewsUrl', 'directionsUrl']);
const ID_KEYS = new Set(['anchorId', 'id', 'htmlId']);
@@ -0,0 +1,47 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { exportBodyHtml } from '../../utils/html-export';
const INITIAL = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
let harness: EditorHarness | null = null;
afterEach(() => { harness?.unmount(); harness = null; });
const ASSETS = [
{ filename: '1_aa_dining-room.jpg', relUrl: 'assets/1_aa_dining-room.jpg', width: 3000, height: 2000 },
{ filename: '2_aa_kitchen.jpg', relUrl: 'assets/2_aa_kitchen.jpg', width: 2400, height: 1600 },
];
const img = (src: string) => ({ type: { resolvedName: 'ImageBlock' }, props: { src, alt: 'a' } });
const section = (...nodes: any[]) => ({ type: { resolvedName: 'Section' }, props: {}, nodes });
const resp = (...sections: any[]) => ({ type: 'replace', scope: 'section', message: 'x', pages: [{ name: 'Home', tree: { type: { resolvedName: 'Container' }, props: {}, nodes: sections } }] });
async function applyWith(r: any, opts: any) {
harness = renderEditorHarness({ initialState: INITIAL });
const ref: { current: ReturnType<typeof useApplyAiResponse> | null } = { current: null };
harness.mountChild(React.createElement(() => { ref.current = useApplyAiResponse(); return null; }));
let out: any;
await harness.act(async () => { out = await ref.current!(r, undefined, opts); });
const { html } = exportBodyHtml(harness.query.serialize()) as any;
return { out, html: String(html) };
}
describe('apply(): image repair runs before the cleaner (real Craft + export; page-level hero rules are unit-tested in sitesmith/asset-repair.test.ts)', () => {
test('an invented upload name never reaches the published HTML; the notes are returned', async () => {
const { out, html } = await applyWith(resp(section(img('assets/ghost-photo.jpg'))), { assets: ASSETS, homePage: true, userText: 'no photo in the hero' });
expect(out.ok).toBe(true);
expect(html).not.toContain('ghost-photo');
expect(html).toContain('placehold.co');
expect(out.imageNotes.join(' ')).toMatch(/not in your uploads/);
});
test('an unloaded asset list (null) and an omitted list change nothing', async () => {
const a = await applyWith(resp(section(img('assets/ghost-photo.jpg'))), { assets: null });
expect(a.html).toContain('assets/ghost-photo.jpg');
expect(a.out.imageNotes).toBeUndefined();
harness?.unmount();
const b = await applyWith(resp(section(img('assets/ghost-photo.jpg'))), {});
expect(b.html).toContain('assets/ghost-photo.jpg');
});
});
+18 -2
View File
@@ -9,6 +9,7 @@ import { cleanAiResponse } from '../sitesmith/clean-ai-response';
import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext';
import { effectiveBackgroundOf } from './effective-background';
import { checkPrimaryCtaAccent, type CtaCheck } from '../sitesmith/cta-check';
import { repairAssetUse, type RepairAsset } from '../sitesmith/asset-repair';
import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair';
// Re-exported for existing callers/tests that import sanitizeAiTree/
@@ -70,6 +71,8 @@ export interface ApplyOutcome {
canvas?: AppliedCanvas;
/** Report-only post-checks on a page/site replace (e.g. primary CTA not accent-filled); never shown to the owner. */
checks?: CtaCheck[];
/** Notes about image references repaired / hero photos chosen (round 9); shown with the other notes. */
imageNotes?: string[];
/** Patch only: how many ops actually changed the canvas (an op naming a missing node does nothing). */
changed?: number;
/** Patch only: how many ops the response carried. */
@@ -118,11 +121,24 @@ export function useApplyAiResponse() {
resp: SitesmithResponse,
targetNodeId?: string,
opts: { repairContrast?: boolean; /** The owner's request text (an owner-named button colour exempts the CTA check). */ userText?: string | null;
/** scope=site only: ADD the pages (keep every other page) instead of replacing the site (round 4). */ addPages?: boolean } = {},
/** scope=site only: ADD the pages (keep every other page) instead of replacing the site (round 4). */ addPages?: boolean;
/** Round 9: the site's real uploads (fresh list; null = could not be loaded, nothing is checked; absent = no image repair). */
assets?: RepairAsset[] | null;
/** Filenames the owner left ticked (hero substitutions pick only from these). */ usableAssets?: string[];
/** scope=page: the page being replaced is the home page. */ homePage?: boolean } = {},
): Promise<ApplyOutcome> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
// Round 9: image references that are not real uploads are repaired, and the hero photo rules run, BEFORE the
// cleaner (which would otherwise just drop a proxy URL and leave a hole). Pure, no model call.
let imageNotes: string[] = [];
if (opts.assets !== undefined) {
const rep = repairAssetUse(resp, { assets: opts.assets, usable: opts.usableAssets, userText: opts.userText, homePage: opts.homePage });
resp = rep.resp; imageNotes = rep.notes;
for (const n of imageNotes) console.info(`[sitesmith] image: ${n}`);
}
// G8: allow-list cleaner (URLs, images, CSS, ids; form destinations and head code can never be
// set by the model). Runs first, so nothing below -- contrast repair, the canvas -- sees a
// value the cleaner would refuse. (Server-side mirror not implemented, deferred.)
@@ -148,7 +164,7 @@ export function useApplyAiResponse() {
resp = rep.response; adjustments = rep.adjustments; notChecked = rep.notChecked;
}
const done = (r: { ok: boolean; message?: string; changed?: number; ops?: number; dropped?: PatchDropped }, canvas?: AppliedCanvas): ApplyOutcome =>
(r.ok ? { ...r, adjustments, notChecked, canvas: canvas ?? liveCanvas(), ...(checks.length ? { checks } : {}) } : r);
(r.ok ? { ...r, adjustments, notChecked, canvas: canvas ?? liveCanvas(), ...(checks.length ? { checks } : {}), ...(imageNotes.length ? { imageNotes } : {}) } : r);
const liveCanvas = (): AppliedCanvas => {
try { return { settled: true, expected: canvasFingerprint(query.serialize()) }; } catch { return { settled: true, expected: null }; }
};