fix(sitesmith): placehold.co backgrounds lose their label (it covered the hero headline)

Live case 5 (2026.11.8): BackgroundSection.bgImage =
placehold.co/1920x1080/101a2b/fbfaf7?text=Team+collaborating+... printed the label across
the hero headline on the published page and in the review render. The allow-list cleaner now
rewrites a placehold.co URL used as a background (bgImage, backgroundImageUrl, CallToAction
bgValue, ContentSlider slide imageSrc) to https://placehold.co/WxH/BG/BG (no query, text colour
= box colour, so neither the label nor the "W x H" fallback shows). Runs after the host
allow-list; ordinary image placeholders keep their label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 18:31:12 -08:00
1 parent 2dcf8b7feb
commit 274ae9fc33
2 files changed
+58 -1

No files matched your search

@@ -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');
});
});
+26 -1
View File
@@ -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;