diff --git a/craft/src/components/basic/Logo.tsx b/craft/src/components/basic/Logo.tsx index 429f11e..ef34a3d 100644 --- a/craft/src/components/basic/Logo.tsx +++ b/craft/src/components/basic/Logo.tsx @@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { useSiteDesign } from '../../state/SiteDesignContext'; -import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; +import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape'; /* ---------- Types ---------- */ @@ -104,7 +104,7 @@ Logo.craft = { let innerHtml: string; if (props.type === 'image' && props.imageSrc) { const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' }); - innerHtml = `${escapeAttr(props.text || 'Logo')}`; + innerHtml = `${escapeAttr(props.text || 'Logo')}`; } else { const spanStyle = cssPropsToString({ fontWeight: props.fontWeight || '700', diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx index 7eaf233..5d1b69e 100644 --- a/craft/src/components/basic/Navbar.tsx +++ b/craft/src/components/basic/Navbar.tsx @@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { useSiteDesign } from '../../state/SiteDesignContext'; -import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape'; +import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape'; /* ---------- Types ---------- */ @@ -252,7 +252,7 @@ Navbar.craft = { let logoHtml: string; if (props.logoType === 'image' && props.logoImage) { const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' }); - logoHtml = `${escapeAttr(props.logoText || 'Logo')}`; + logoHtml = `${escapeAttr(props.logoText || 'Logo')}`; } else { const logoStyle = cssPropsToString({ fontWeight: '700', diff --git a/craft/src/components/media/ImageBlock.render.test.tsx b/craft/src/components/media/ImageBlock.render.test.tsx new file mode 100644 index 0000000..d87dcb4 --- /dev/null +++ b/craft/src/components/media/ImageBlock.render.test.tsx @@ -0,0 +1,62 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/* ImageBlock only needs useNode from @craftjs/core. Mock it following the + DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no + @testing-library/react in this repo) so we can render the real component + tree and inspect the emitted without a real . */ +vi.mock('@craftjs/core', () => ({ + useNode: (collect?: (node: any) => any) => { + const node = { events: { selected: false } }; + return { + connectors: { connect: (el: any) => el, drag: (el: any) => el }, + actions: { setProp: vi.fn() }, + ...(collect ? collect(node) : {}), + }; + }, +})); + +import { ImageBlock } from './ImageBlock'; + +let container: HTMLDivElement; +let root: Root; + +function render(ui: React.ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render(ui); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => { + test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).not.toBe(''); + expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/); + container.remove(); + }); + + test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).not.toBe(''); + expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/); + container.remove(); + }); + + test('a real src is rendered unchanged', () => { + render(); + const img = container.querySelector('img')!; + expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg'); + container.remove(); + }); +}); diff --git a/craft/src/components/media/ImageBlock.tsx b/craft/src/components/media/ImageBlock.tsx index 11052fc..1320790 100644 --- a/craft/src/components/media/ImageBlock.tsx +++ b/craft/src/components/media/ImageBlock.tsx @@ -1,9 +1,9 @@ import React, { CSSProperties, useCallback, useRef } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; -import { escapeAttr, safeUrl } from '../../utils/escape'; +import { escapeAttr, safeImageUrl } from '../../utils/escape'; -const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; +export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; interface ImageBlockProps { src?: string; @@ -66,7 +66,7 @@ export const ImageBlock: UserComponent = ({ imgRef.current = ref; if (ref) connect(drag(ref)); }} - src={src} + src={src || PLACEHOLDER_SRC} alt={alt || 'Image'} onDrop={handleDrop} onDragOver={handleDragOver} @@ -95,5 +95,5 @@ ImageBlock.craft = { } const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""'; - return { html: `` }; + return { html: `` }; }; diff --git a/craft/src/components/sections/ContentSlider.toHtml.test.ts b/craft/src/components/sections/ContentSlider.toHtml.test.ts index f1b1a15..d4b147d 100644 --- a/craft/src/components/sections/ContentSlider.toHtml.test.ts +++ b/craft/src/components/sections/ContentSlider.toHtml.test.ts @@ -109,6 +109,15 @@ describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT expect(html).not.toContain('background-image:url('); expect(html).toContain('background-color:#123456'); }); + + test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => { + const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E'; + const slidesWithSvg = [ + { type: 'image' as const, imageSrc: svgDataUri, heading: 'One' }, + ]; + const { html } = toHtml({ slides: slidesWithSvg }, ''); + expect(html).toContain(`background-image:url('${svgDataUri}')`); + }); }); describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline