diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx index 248dcc0..0820961 100644 --- a/craft/src/panels/sitesmith/SitesmithModal.tsx +++ b/craft/src/panels/sitesmith/SitesmithModal.tsx @@ -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 = ({ 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 => { + 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 = ({ 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', diff --git a/craft/src/sitesmith/asset-repair.test.ts b/craft/src/sitesmith/asset-repair.test.ts new file mode 100644 index 0000000..687adfe --- /dev/null +++ b/craft/src/sitesmith/asset-repair.test.ts @@ -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 => ({ 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 = {}, nodes?: SerializedTreeNode[]): SerializedTreeNode => ({ type: { resolvedName: name }, props, ...(nodes ? { nodes } : {}) }); +const page = (name: string, ...sections: SerializedTreeNode[]) => ({ name, tree: n('Container', {}, sections) }); +const site = (...pages: ReturnType[]): 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 = {}) => n('BackgroundSection', { bgImage: bg, overlayOpacity: 0.4, ...extra }, [n('Heading', { text: 'Hi' })]); +const pagesOf = (r: SitesmithResponse) => (r as any).pages as ReturnType[]; +const run = (resp: SitesmithResponse, extra: Record = {}) => 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'); + }); +}); diff --git a/craft/src/sitesmith/asset-repair.ts b/craft/src/sitesmith/asset-repair.ts new file mode 100644 index 0000000..b37935d --- /dev/null +++ b/craft/src/sitesmith/asset-repair.ts @@ -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/` (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/`. 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 = (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, lower: Map, 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, ctx: { assets: RepairAsset[]; byName: Map; lower: Map; 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 { + const out = new Set(); + 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, 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) { + 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; + 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): 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, a: RepairAsset): string { + const p = slot.node.props as Record; + 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(); + 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([...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 & { 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 }; +} diff --git a/craft/src/sitesmith/clean-ai-response.ts b/craft/src/sitesmith/clean-ai-response.ts index c858b53..98826d3 100644 --- a/craft/src/sitesmith/clean-ai-response.ts +++ b/craft/src/sitesmith/clean-ai-response.ts @@ -41,7 +41,7 @@ export const AI_FORBIDDEN_PROPS: ReadonlySet = 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']); diff --git a/craft/src/test-utils/integration/asset-repair.integration.test.tsx b/craft/src/test-utils/integration/asset-repair.integration.test.tsx new file mode 100644 index 0000000..1e4c0d1 --- /dev/null +++ b/craft/src/test-utils/integration/asset-repair.integration.test.tsx @@ -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 | 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'); + }); +}); diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 57df476..7ad07ed 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -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 { // '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 }; } };