diff --git a/craft/src/sitesmith/clean-ai-response.test.ts b/craft/src/sitesmith/clean-ai-response.test.ts index 1772b9e..2d2e588 100644 --- a/craft/src/sitesmith/clean-ai-response.test.ts +++ b/craft/src/sitesmith/clean-ai-response.test.ts @@ -189,3 +189,35 @@ describe('cssValueAllowed: positive grammar (review finding 2)', () => { 'url(https://example.com/x.png)', 'url(//example.com/x.png)', 'red \\9', 'red\u0001', 'var(x)', 'red !important', ])('rejects %s', (v) => expect(cssValueAllowed(v)).toBe(false)); }); + +// ---- round 4 item 4 (live case 5): a placehold.co image used as a BACKGROUND drew its ?text= label right +// across the hero headline (published page and review render alike). Background uses lose the label: no +// text, and the text colour = the box colour (placehold.co prints "W x H" when there is no text). +describe('placehold.co backgrounds are label-free', () => { + const LIVE = 'https://placehold.co/1920x1080/101a2b/fbfaf7?text=Team+collaborating+in+a+modern+office'; + test('BackgroundSection.bgImage from the live case', () => { + expect(cleanAiProps({ bgImage: LIVE }, 'BackgroundSection').bgImage).toBe('https://placehold.co/1920x1080/101a2b/101a2b'); + }); + test('HeroSimple.bgImage, backgroundImageUrl, CallToAction bgValue, ContentSlider slide images', () => { + expect(cleanAiProps({ bgImage: 'https://placehold.co/1600x900?text=Hero' }, 'HeroSimple').bgImage).toBe('https://placehold.co/1600x900/dddddd/dddddd'); + expect(cleanAiProps({ backgroundImageUrl: 'https://placehold.co/1600x900/abc.png?text=x' }, 'Section').backgroundImageUrl).toBe('https://placehold.co/1600x900/aabbcc/aabbcc.png'); + expect(cleanAiProps({ bgType: 'image', bgValue: 'https://placehold.co/1200x600/222222/ffffff?text=Band' }, 'CallToAction').bgValue).toBe('https://placehold.co/1200x600/222222/222222'); + expect((cleanAiProps({ slides: [{ imageSrc: 'https://placehold.co/1200x600/000000/ffffff?text=Slide', heading: 'Hi' }] }, 'ContentSlider').slides as any)[0].imageSrc).toBe('https://placehold.co/1200x600/000000/000000'); + }); + test('an ordinary image keeps its label (it tells the owner what photo to source); non-placehold URLs untouched', () => { + const img = 'https://placehold.co/800x600/eeeeee/333333?text=Team+photo'; + expect(cleanAiProps({ src: img }, 'ImageBlock').src).toBe(img); + expect(cleanAiProps({ bgImage: 'assets/hero.jpg' }, 'BackgroundSection').bgImage).toBe('assets/hero.jpg'); + expect(cleanAiProps({ bgImage: 'https://images.pexels.com/photos/1/a.jpg?text=x' }, 'BackgroundSection').bgImage).toBe('https://images.pexels.com/photos/1/a.jpg?text=x'); + }); + test('the allow-list still applies first (a disallowed host is still dropped)', () => { + expect(cleanAiProps({ bgImage: 'https://evil.example/1920x1080?text=x' }, 'BackgroundSection').bgImage).toBeUndefined(); + }); + test('an off-spec placehold.co background path loses its query (label) and is otherwise left alone', () => { + expect(cleanAiProps({ bgImage: 'https://placehold.co/1920x1080/navy/white?text=Hi&font=roboto' }, 'BackgroundSection').bgImage).toBe('https://placehold.co/1920x1080/navy/white'); + }); + test('patch ops are cleaned the same way', () => { + const out = cleanAiResponse({ type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'ai-hero-1', props: { bgImage: LIVE } }] } as any, () => 'BackgroundSection') as any; + expect(out.ops[0].props.bgImage).toBe('https://placehold.co/1920x1080/101a2b/101a2b'); + }); +}); diff --git a/craft/src/sitesmith/clean-ai-response.ts b/craft/src/sitesmith/clean-ai-response.ts index 0231462..33bde68 100644 --- a/craft/src/sitesmith/clean-ai-response.ts +++ b/craft/src/sitesmith/clean-ai-response.ts @@ -145,6 +145,30 @@ function urlKindFor(key: string, component: string | undefined): UrlKind | null return null; } +/** Props that paint an image BEHIND content (component toHtml uses them as CSS background-image). */ +function isBackgroundImage(key: string, component: string | undefined): boolean { + return key === 'bgImage' || key === 'backgroundImageUrl' || key === 'bgValue' || (component === 'ContentSlider' && key === 'imageSrc'); +} + +const PLACEHOLD_PATH = /^\/(\d{1,4})x(\d{1,4})(?:\/([0-9a-f]{3}|[0-9a-f]{6}))?(?:\/([0-9a-f]{3}|[0-9a-f]{6}))?(\.(?:png|jpe?g|gif|webp|avif|svg))?$/i; +const hex6 = (h: string) => (h.length === 3 ? h.split('').map((c) => c + c).join('') : h).toLowerCase(); + +/** + * Round 4 (live case 5): a placehold.co image used as a BACKGROUND prints its label (`?text=`, or "W x H" + * without one) across whatever sits on top, e.g. the hero headline. Background placeholders lose the label: + * the query is dropped and the text colour is set to the box colour. Called only on a URL the allow-list + * already accepted; anything that is not a placehold.co URL is returned unchanged. + */ +export function labelFreePlaceholder(url: string): string { + let u: URL; + try { u = new URL(url); } catch { return url; } + if (u.hostname.toLowerCase() !== 'placehold.co') return url; + const m = PLACEHOLD_PATH.exec(u.pathname); + if (!m) return `https://placehold.co${u.pathname}`; + const bg = m[3] ? hex6(m[3]) : 'dddddd'; + return `https://placehold.co/${m[1]}x${m[2]}/${bg}/${bg}${m[5] ?? ''}`; +} + /** Clean one value under `key`; returns undefined to drop it. */ function cleanValue(key: string, value: unknown, component: string | undefined, depth: number): unknown { if (depth > 8) return undefined; @@ -158,12 +182,13 @@ function cleanValue(key: string, value: unknown, component: string | undefined, } if (value && typeof value === 'object') return cleanObject(value, component, depth + 1); const ok = allowUrl(kind, value); + if (ok && kind === 'image' && isBackgroundImage(key, component)) return labelFreePlaceholder(ok); return ok === null ? undefined : ok; } // CallToAction.bgValue is a colour/gradient, or an image URL when bgType is 'image'. if (key === 'bgValue' && typeof value === 'string' && /^\s*([a-z][a-z0-9+.-]*:|\/|assets\/)/i.test(value)) { const ok = allowUrl('image', value); - return ok === null ? undefined : ok; + return ok === null ? undefined : ok === '' ? ok : labelFreePlaceholder(ok); } if (ID_KEYS.has(key)) return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : undefined; if (key === 'icon') return typeof value === 'string' && /^[A-Za-z0-9 _-]{0,80}$/.test(value) ? value : undefined;