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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user