fix: Optimize images in Markdown Embed (#6089)

## Summary

Markdown Embed now renders image tags with the same optimized attributes
and canvas behavior as the SDK Image component.

The implementation shares the complete attribute-resolution path without
rendering React inside the Markdown pipeline:

- `@webstudio-is/image` exposes one framework-independent
`getImageAttributes` function for optimization, placeholder fallback,
loading, decoding, dimensions, alternative text, and responsive sources.
- React-only element props such as `className`, `style`, handlers,
context, and refs stay in React callers.
- The SDK Image component and Markdown renderer both use
`getSdkImageProps` for canvas-specific loading, decoding, asset
identity, and incomplete-upload handling.
- Markdown retains only the necessary HTML attribute-name conversion,
sanitization, and Safari-safe `sizes` / `srcset` / `src` ordering.
- The redundant low-level React Image primitive was removed. Its callers
now render native `img` elements, and the image package no longer has
React runtime or peer dependencies.

## Compatibility and tradeoffs

- The SDK Image component remains the public React component because it
owns React context and ref forwarding.
- The removed low-level `@webstudio-is/image` Image export is replaced
by the pure attribute function for internal callers.
- No data migration or schema change is required.
- Loader output is sanitized after transformation, while unsafe input
URLs are sanitized before reaching the loader.

## Todo

- [x] Share image attribute resolution between native, SDK, and Markdown
images.
- [x] Preserve SDK canvas behavior for Markdown images.
- [x] Preserve safe URL sanitization and Safari-safe responsive
attribute ordering.
- [x] Remove the redundant low-level React image primitive and migrate
its callers.
- [x] Remove the overlapping `getImageProps` abstraction.
- [x] Add regression coverage comparing Markdown, SDK Image, and native
attribute output.
- [x] Review documentation impact and update the Markdown Embed
documentation.
- [x] Run targeted tests, typechecks, builds, lint, formatting,
package-boundary checks, and fixture generation.

## Verification

- `pnpm --filter @webstudio-is/image test`
- `pnpm --filter @webstudio-is/image typecheck`
- `pnpm --filter @webstudio-is/image build`
- `pnpm --filter @webstudio-is/image dts`
- `pnpm --filter @webstudio-is/sdk-components-react test`
- `pnpm --filter @webstudio-is/sdk-components-react typecheck`
- `pnpm --filter @webstudio-is/sdk-components-react build`
- `pnpm --filter @webstudio-is/sdk-components-react dts`
- `pnpm --filter @webstudio-is/builder typecheck`
- `pnpm --filter @webstudio-is/design-system typecheck`
- Targeted Oxlint and Oxfmt checks
- `pnpm check:package-boundaries`
- `pnpm fixtures` (no fixture diff)

Ref #6014
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-08-10 18:21:45 +02:00
1 parent 50958a68b3
commit db3b11970f
25 files changed
+542 -244

No files matched your search

@@ -7,7 +7,7 @@ import {
css,
rawTheme,
} from "@webstudio-is/design-system";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { SpinnerIcon } from "@webstudio-is/icons";
const focusOutline = focusRingStyle();
@@ -73,11 +73,13 @@ const Thumbnail = ({ image, state, alt }: ThumbnailProps) => {
// Its a URL.
<img src={image} className={imageStyle({ hasAsset: true })} />
) : (
<Image
src={image.name}
width={rawTheme.spacing[28]}
loader={wsImageLoader}
<img
className={imageStyle({ hasAsset: true })}
{...getImageAttributes({
src: image.name,
width: rawTheme.spacing[28],
loader: wsImageLoader,
})}
/>
)}
@@ -1,5 +1,5 @@
import { Box, Flex, Grid } from "@webstudio-is/design-system";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { formatUrl, truncateByWords, truncate } from "./social-utils";
/**
@@ -92,11 +92,13 @@ export const SearchPreview = (props: SearchPreviewProps) => {
justify={"center"}
align={"center"}
>
<Image
width={18}
height={18}
loader={wsImageLoader}
src={props.faviconUrl}
<img
{...getImageAttributes({
width: 18,
height: 18,
loader: wsImageLoader,
src: props.faviconUrl,
})}
/>
</Flex>
<Grid>
@@ -1,5 +1,5 @@
import { Box, Grid, Label, css, theme } from "@webstudio-is/design-system";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { truncateByWords, truncate } from "./social-utils";
type SocialPreviewProps = {
@@ -43,14 +43,16 @@ export const SocialPreview = ({
backgroundColor: theme.colors.white,
}}
>
<Image
alt="Social sharing preview image"
src={ogImageUrl}
loader={wsImageLoader}
<img
className={imgStyle({
hasImage:
ogImageUrl === undefined || ogImageUrl === "" ? false : true,
})}
{...getImageAttributes({
alt: "Social sharing preview image",
src: ogImageUrl,
loader: wsImageLoader,
})}
/>
<Grid
@@ -1,6 +1,6 @@
import { useStore } from "@nanostores/react";
import type { Assets } from "@webstudio-is/sdk";
import { Image as WebstudioImage, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { styled, theme } from "@webstudio-is/design-system";
import {
type StyleValue,
@@ -44,7 +44,7 @@ const NoneThumbnail = styled(Thumbnail, {
"repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%",
});
const StyledWebstudioImage = styled(WebstudioImage, {
const StyledImage = styled("img", {
position: "relative",
width: thumbSize,
height: thumbSize,
@@ -144,12 +144,14 @@ export const BackgroundThumbnail = ({ index }: { index: number }) => {
return;
}
return (
<StyledWebstudioImage
<StyledImage
key={asset.id}
loader={wsImageLoader}
src={asset.name}
width={thumbSize}
optimize={true}
{...getImageAttributes({
loader: wsImageLoader,
src: asset.name,
width: thumbSize,
optimize: true,
})}
/>
);
}
@@ -159,12 +161,14 @@ export const BackgroundThumbnail = ({ index }: { index: number }) => {
backgroundImageValue.value.type === "url"
) {
return (
<StyledWebstudioImage
<StyledImage
key={backgroundImageValue.value.url}
loader={wsImageLoader}
src={backgroundImageValue.value.url}
width={thumbSize}
optimize={true}
{...getImageAttributes({
loader: wsImageLoader,
src: backgroundImageValue.value.url,
width: thumbSize,
optimize: true,
})}
/>
);
}
@@ -1,4 +1,4 @@
import { Image as WebstudioImage, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
type ImageProps = {
assetId: string;
@@ -26,7 +26,8 @@ export const Image = ({
const src = objectURL ?? name;
return (
<WebstudioImage
<img
key={assetId}
className={className}
style={{
// Prevent native image drag in Image Manager to avoid issues with monitorForExternal
@@ -37,13 +38,14 @@ export const Image = ({
WebkitUserDrag: "none",
maxWidth: "100%",
}}
key={assetId}
loader={wsImageLoader}
decoding={decoding}
src={src}
width={width}
optimize={optimize}
alt={alt}
{...getImageAttributes({
loader: wsImageLoader,
decoding,
src,
width,
optimize,
alt,
})}
/>
);
};
@@ -6,7 +6,7 @@ import type { AssetUploadResult } from "@webstudio-is/protocol/asset-resource-ap
import type { AssetType, UploadTicket } from "@webstudio-is/asset-uploader";
import { Box, toast, css, theme } from "@webstudio-is/design-system";
import { sanitizeS3Key } from "@webstudio-is/asset-uploader";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { restAssetsUploadPath } from "~/shared/router-utils";
import { fetch } from "~/shared/fetch.client";
import {
@@ -373,12 +373,14 @@ const imageWidth = css({
const ToastImageInfo = ({ objectURL }: { objectURL: string }) => {
return (
<Box css={{ width: theme.spacing[18] }}>
<Image
<img
className={imageWidth()}
src={objectURL}
optimize={false}
width={64}
loader={wsImageLoader}
{...getImageAttributes({
src: objectURL,
optimize: false,
width: 64,
loader: wsImageLoader,
})}
/>
</Box>
);
@@ -1,5 +1,5 @@
import { forwardRef } from "react";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { css, theme, textVariants } from "@webstudio-is/design-system";
const abbrStyle = css(textVariants.brandThumbnailLargeDefault, {
@@ -74,7 +74,13 @@ export const ThumbnailLinkWithImage = forwardRef<
>(({ name, to }, ref) => {
return (
<a ref={ref} href={to} className={imageContainerStyle()} tabIndex={-1}>
<Image src={name} loader={wsImageLoader} className={imageStyle()} />
<img
className={imageStyle()}
{...getImageAttributes({
src: name,
loader: wsImageLoader,
})}
/>
</a>
);
});
@@ -94,7 +100,13 @@ export const ThumbnailWithImage = forwardRef<
className={imageContainerStyle()}
tabIndex={-1}
>
<Image src={name} loader={wsImageLoader} className={imageStyle()} />
<img
className={imageStyle()}
{...getImageAttributes({
src: name,
loader: wsImageLoader,
})}
/>
</div>
);
});
@@ -17,7 +17,7 @@ import {
IconButton,
} from "@webstudio-is/design-system";
import { CopyIcon, InfoCircleIcon } from "@webstudio-is/icons";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import type { ProjectMeta } from "@webstudio-is/sdk";
import { validateContactEmail } from "@webstudio-is/project-build/contracts";
import { ImageControl } from "./image-control";
@@ -172,12 +172,14 @@ export const SectionGeneral = ({ projectId }: { projectId?: string }) => {
<Grid gap={2} css={sectionSpacing} justify={"start"}>
<Label>Favicon</Label>
<Grid flow="column" gap={3}>
<Image
width={72}
height={72}
<img
className={imgStyle()}
src={favIconUrl}
loader={wsImageLoader}
{...getImageAttributes({
width: 72,
height: 72,
src: favIconUrl,
loader: wsImageLoader,
})}
/>
<Grid gap={2}>
@@ -16,7 +16,7 @@ import {
Select,
Box,
} from "@webstudio-is/design-system";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { getImageAttributes, wsImageLoader } from "@webstudio-is/image";
import { useState } from "react";
import {
type MarketplaceProduct,
@@ -218,12 +218,14 @@ export const SectionMarketplace = () => {
<InputErrorsTooltip errors={errors?.thumbnailAssetId}>
<Grid flow="column" gap={3}>
<Box className={thumbnailStyle()}>
<Image
<img
className={thumbnailImageStyle({
hasAsset: asset !== undefined,
})}
src={asset ? `${asset.name}` : undefined}
loader={wsImageLoader}
{...getImageAttributes({
src: asset ? `${asset.name}` : undefined,
loader: wsImageLoader,
})}
/>
</Box>
@@ -34,11 +34,9 @@ Styles applied to each of these tags will apply to all occurrences of that tag w
## Image handling
Webstudio does NOT optimize images contained in the markup.
Webstudio optimizes images contained in Markdown with the same responsive image pipeline as the [Image component](image.md#optimize). It generates the appropriate `srcset` and `sizes` attributes and lazy-loads images by default.
On the other hand, images bound to the [Image Component](image.md) are [optimized by default](image.md#optimize).
The difference is in markup, we are not mapping every element to a Webstudio Component; rather, the element is served as-is with the exception of your custom styles applied to it.
Data URL images are served as-is without optimization.
## Similar components
@@ -1,6 +1,6 @@
import { Toast as ToastComponent } from "./toast";
import { StorySection, StoryGrid } from "./storybook";
import { Image } from "@webstudio-is/image";
import { getImageAttributes } from "@webstudio-is/image";
import { Box } from "./box";
import { css, theme } from "../stitches.config";
import { AlertCircleIcon } from "@webstudio-is/icons";
@@ -24,12 +24,14 @@ const imageWidth = css({
const ImageIcon = () => (
<Box css={{ width: theme.spacing[18] }}>
<Image
<img
className={imageWidth()}
src={toastIconUrl}
optimize={false}
width={64}
loader={() => ""}
{...getImageAttributes({
src: toastIconUrl,
optimize: false,
width: 64,
loader: () => "",
})}
/>
</Box>
);
-5
View File
@@ -13,7 +13,6 @@
},
"dependencies": {
"@webstudio-is/sdk": "workspace:*",
"react": "18.3.0-canary-14898b6a9-20240318",
"warn-once": "^0.1.1"
},
"devDependencies": {
@@ -25,10 +24,6 @@
"react-dom": "18.3.0-canary-14898b6a9-20240318",
"vitest": "^3.1.2"
},
"peerDependencies": {
"react": "18.3.0-canary-14898b6a9-20240318",
"react-dom": "18.3.0-canary-14898b6a9-20240318"
},
"exports": {
"webstudio": "./src/index.ts",
"types": "./lib/types/index.d.ts",
+28 -13
View File
@@ -2,7 +2,7 @@
import type * as React from "react";
import { StorySection } from "@webstudio-is/design-system";
import { Image as ImagePrimitive, wsImageLoader } from "./";
import { getImageAttributes, wsImageLoader } from "./";
// to not allow include local assets everywhere, just enable it for this file
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -13,8 +13,6 @@ export default {
title: "Image dev",
};
type ImageProps = React.ComponentProps<typeof ImagePrimitive>;
/**
* In case you need to test img with real cloudflare trasforms
* set USE_CLOUDFLARE_IMAGE_TRANSFORM = true
@@ -28,23 +26,40 @@ const imageSrc = USE_CLOUDFLARE_IMAGE_TRANSFORM
? REMOTE_SELF_DOMAIN_IMAGE
: localLogoImage;
const ImageBase = (
args: Omit<ImageProps, "loader"> & {
style?: React.HTMLAttributes<"img">["style"];
}
) => {
const ImageBase = ({
alt,
decoding,
height,
loading,
sizes,
src,
srcSet,
style: inputStyle,
width,
...props
}: React.ComponentPropsWithoutRef<"img">) => {
const style = {
maxWidth: "100%",
display: "block",
...args.style,
...inputStyle,
};
return (
<ImagePrimitive
{...args}
optimize={true}
loader={wsImageLoader}
<img
{...props}
style={style}
{...getImageAttributes({
alt,
decoding,
height,
loading,
sizes,
src,
srcSet,
width,
optimize: true,
loader: wsImageLoader,
})}
/>
);
};
+47 -3
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { getImageAttributes } from "./image-optimize";
import { getImageAttributes, imagePlaceholderDataUrl } from "./image-optimize";
import { wsImageLoader } from "./image-loaders";
describe("Image optimizations applied", () => {
@@ -16,9 +16,13 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "100vw",
"src": "/cgi/image/logo.webp?width=256&quality=100&format=auto",
"srcSet": "/cgi/image/logo.webp?width=16&quality=100&format=auto 16w, /cgi/image/logo.webp?width=32&quality=100&format=auto 32w, /cgi/image/logo.webp?width=48&quality=100&format=auto 48w, /cgi/image/logo.webp?width=64&quality=100&format=auto 64w, /cgi/image/logo.webp?width=96&quality=100&format=auto 96w, /cgi/image/logo.webp?width=128&quality=100&format=auto 128w, /cgi/image/logo.webp?width=256&quality=100&format=auto 256w",
"width": 100,
}
`);
});
@@ -36,9 +40,13 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "100vw",
"src": "/cgi/image/https%3A//webstudio.is/logo.webp?width=256&quality=100&format=auto",
"srcSet": "/cgi/image/https%3A//webstudio.is/logo.webp?width=16&quality=100&format=auto 16w, /cgi/image/https%3A//webstudio.is/logo.webp?width=32&quality=100&format=auto 32w, /cgi/image/https%3A//webstudio.is/logo.webp?width=48&quality=100&format=auto 48w, /cgi/image/https%3A//webstudio.is/logo.webp?width=64&quality=100&format=auto 64w, /cgi/image/https%3A//webstudio.is/logo.webp?width=96&quality=100&format=auto 96w, /cgi/image/https%3A//webstudio.is/logo.webp?width=128&quality=100&format=auto 128w, /cgi/image/https%3A//webstudio.is/logo.webp?width=256&quality=100&format=auto 256w",
"width": 100,
}
`);
});
@@ -56,6 +64,9 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "(min-width: 1280px) 50vw, 100vw",
"src": "/cgi/image/logo.webp?width=3840&quality=90&format=auto",
"srcSet": "/cgi/image/logo.webp?width=384&quality=90&format=auto 384w, /cgi/image/logo.webp?width=640&quality=90&format=auto 640w, /cgi/image/logo.webp?width=750&quality=90&format=auto 750w, /cgi/image/logo.webp?width=828&quality=90&format=auto 828w, /cgi/image/logo.webp?width=1080&quality=90&format=auto 1080w, /cgi/image/logo.webp?width=1200&quality=90&format=auto 1200w, /cgi/image/logo.webp?width=1920&quality=90&format=auto 1920w, /cgi/image/logo.webp?width=2048&quality=90&format=auto 2048w, /cgi/image/logo.webp?width=3840&quality=90&format=auto 3840w",
@@ -76,6 +87,9 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "100vw",
"src": "/cgi/image/logo.webp?width=3840&quality=70&format=auto",
"srcSet": "/cgi/image/logo.webp?width=640&quality=70&format=auto 640w, /cgi/image/logo.webp?width=750&quality=70&format=auto 750w, /cgi/image/logo.webp?width=828&quality=70&format=auto 828w, /cgi/image/logo.webp?width=1080&quality=70&format=auto 1080w, /cgi/image/logo.webp?width=1200&quality=70&format=auto 1200w, /cgi/image/logo.webp?width=1920&quality=70&format=auto 1920w, /cgi/image/logo.webp?width=2048&quality=70&format=auto 2048w, /cgi/image/logo.webp?width=3840&quality=70&format=auto 3840w",
@@ -96,6 +110,9 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "100vw",
"src": "/cgi/image/logo.webp?width=3840&quality=70&format=auto",
"srcSet": "/cgi/image/logo.webp?width=640&quality=70&format=auto 640w, /cgi/image/logo.webp?width=750&quality=70&format=auto 750w, /cgi/image/logo.webp?width=828&quality=70&format=auto 828w, /cgi/image/logo.webp?width=1080&quality=70&format=auto 1080w, /cgi/image/logo.webp?width=1200&quality=70&format=auto 1200w, /cgi/image/logo.webp?width=1920&quality=70&format=auto 1920w, /cgi/image/logo.webp?width=2048&quality=70&format=auto 2048w, /cgi/image/logo.webp?width=3840&quality=70&format=auto 3840w",
@@ -119,6 +136,9 @@ describe("Image optimizations applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"sizes": "100vw",
"src": "/logo.webp?w=3840&q=70",
"srcSet": "/logo.webp?w=640&q=70 640w, /logo.webp?w=750&q=70 750w, /logo.webp?w=828&q=70 828w, /logo.webp?w=1080&q=70 1080w, /logo.webp?w=1200&q=70 1200w, /logo.webp?w=1920&q=70 1920w, /logo.webp?w=2048&q=70 2048w, /logo.webp?w=3840&q=70 3840w",
@@ -141,7 +161,11 @@ describe("Image optimizations not applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"src": "/cgi/image/logo.webp?format=raw",
"width": 100,
}
`);
});
@@ -159,7 +183,11 @@ describe("Image optimizations not applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"src": "https://webstudio.is/logo.webp",
"width": 100,
}
`);
});
@@ -177,8 +205,12 @@ describe("Image optimizations not applied", () => {
expect(imgAttr).toMatchInlineSnapshot(`
{
"alt": "",
"decoding": "async",
"loading": "lazy",
"src": "https://webstudio.is/logo.webp",
"srcSet": "user-defined-srcset",
"width": 100,
}
`);
});
@@ -194,7 +226,13 @@ describe("Image optimizations not applied", () => {
loader: wsImageLoader,
});
expect(imgAttr).toMatchInlineSnapshot(`undefined`);
expect(imgAttr).toEqual({
alt: "",
decoding: "async",
loading: "lazy",
src: imagePlaceholderDataUrl,
width: 100,
});
});
test("src is undefined", () => {
@@ -208,6 +246,12 @@ describe("Image optimizations not applied", () => {
loader: wsImageLoader,
});
expect(imgAttr).toMatchInlineSnapshot(`undefined`);
expect(imgAttr).toEqual({
alt: "",
decoding: "async",
loading: "lazy",
src: imagePlaceholderDataUrl,
width: 100,
});
});
});
+79 -1
View File
@@ -98,6 +98,30 @@ export type ImageLoader = (
| { src: string; format: "raw" }
) => string;
export const imagePlaceholderDataUrl: string = `data:image/svg+xml;base64,${btoa(`<svg
width="140"
height="140"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="600" height="600" fill="#DFE3E6" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M450 170H150C141.716 170 135 176.716 135 185V415C135 423.284 141.716 430 150 430H450C458.284 430 465 423.284 465 415V185C465 176.716 458.284 170 450 170ZM150 145C127.909 145 110 162.909 110 185V415C110 437.091 127.909 455 150 455H450C472.091 455 490 437.091 490 415V185C490 162.909 472.091 145 450 145H150Z"
fill="#C1C8CD"
/>
<path
d="M237.135 235.012C237.135 255.723 220.345 272.512 199.635 272.512C178.924 272.512 162.135 255.723 162.135 235.012C162.135 214.301 178.924 197.512 199.635 197.512C220.345 197.512 237.135 214.301 237.135 235.012Z"
fill="#C1C8CD"
/>
<path
d="M160 405V367.205L221.609 306.364L256.552 338.628L358.161 234L440 316.043V405H160Z"
fill="#C1C8CD"
/>
</svg>`)}`;
/**
* max(...imageSizes) must be less then min(...deviceSizes)
**/
@@ -244,7 +268,7 @@ const UrlCanParse = (url: string): boolean => {
}
};
export const getImageAttributes = (props: {
const getOptimizedImageAttributes = (props: {
src: string | undefined;
srcSet: string | undefined;
sizes: string | undefined;
@@ -306,3 +330,57 @@ export const getImageAttributes = (props: {
return resAttrs;
}
};
export type ImageAttributes = {
alt?: string;
decoding?: "async" | "auto" | "sync";
height?: number | string;
loading?: "eager" | "lazy";
sizes?: string;
src?: string;
srcSet?: string;
width?: number | string;
};
type ImageOptions = {
loader: ImageLoader;
/** Optimize the image for enhanced performance. */
optimize?: boolean;
quality?: number;
};
export type ResolvedImageAttributes = ImageAttributes & {
alt: string | undefined;
decoding: "async" | "auto" | "sync";
loading: "eager" | "lazy";
src: string;
};
export const getImageAttributes = ({
quality,
loader,
optimize = true,
loading = "lazy",
decoding = "async",
...attributes
}: ImageAttributes & ImageOptions): ResolvedImageAttributes => {
const optimizedAttributes = getOptimizedImageAttributes({
src: attributes.src,
srcSet: attributes.srcSet,
sizes: attributes.sizes,
width: attributes.width,
quality,
loader,
optimize,
}) ?? { src: imagePlaceholderDataUrl };
return {
alt: "",
...(attributes.alt !== undefined ? { alt: attributes.alt } : {}),
...(attributes.width !== undefined ? { width: attributes.width } : {}),
...(attributes.height !== undefined ? { height: attributes.height } : {}),
...optimizedAttributes,
decoding,
loading,
};
};
-76
View File
@@ -1,76 +0,0 @@
import {
forwardRef,
type ComponentProps,
type ForwardRefExoticComponent,
} from "react";
import { getImageAttributes, type ImageLoader } from "./image-optimize";
const defaultTag = "img";
type ImageProps = ComponentProps<typeof defaultTag> & {
quality?: number;
/** Optimize the image for enhanced performance. */
optimize?: boolean;
loader: ImageLoader;
};
export const Image: ForwardRefExoticComponent<ImageProps> = forwardRef(
(
{
quality,
loader,
optimize = true,
loading = "lazy",
decoding = "async",
...imageProps
},
ref
) => {
const imageAttributes = getImageAttributes({
src: imageProps.src,
srcSet: imageProps.srcSet,
sizes: imageProps.sizes,
width: imageProps.width,
quality,
loader,
optimize,
}) ?? { src: imagePlaceholderDataUrl };
return (
<img
alt=""
{...imageProps}
{...imageAttributes}
decoding={decoding}
loading={loading}
ref={ref}
/>
);
}
);
Image.displayName = "Image";
export const imagePlaceholderDataUrl: string = `data:image/svg+xml;base64,${btoa(`<svg
width="140"
height="140"
viewBox="0 0 600 600"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="600" height="600" fill="#DFE3E6" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M450 170H150C141.716 170 135 176.716 135 185V415C135 423.284 141.716 430 150 430H450C458.284 430 465 423.284 465 415V185C465 176.716 458.284 170 450 170ZM150 145C127.909 145 110 162.909 110 185V415C110 437.091 127.909 455 150 455H450C472.091 455 490 437.091 490 415V185C490 162.909 472.091 145 450 145H150Z"
fill="#C1C8CD"
/>
<path
d="M237.135 235.012C237.135 255.723 220.345 272.512 199.635 272.512C178.924 272.512 162.135 255.723 162.135 235.012C162.135 214.301 178.924 197.512 199.635 197.512C220.345 197.512 237.135 214.301 237.135 235.012Z"
fill="#C1C8CD"
/>
<path
d="M160 405V367.205L221.609 306.364L256.552 338.628L358.161 234L440 316.043V405H160Z"
fill="#C1C8CD"
/>
</svg>`)}`;
-1
View File
@@ -1,4 +1,3 @@
export { Image, imagePlaceholderDataUrl } from "./image";
export type { ImageLoader } from "./image-optimize";
export * from "./image-loaders";
export * from "./image-optimize";
@@ -0,0 +1,77 @@
import { getImageAttributes, type ImageLoader } from "@webstudio-is/image";
import type { ComponentPropsWithoutRef } from "react";
export type SdkImageProps = ComponentPropsWithoutRef<"img"> & {
quality?: number;
/** Optimize the image for enhanced performance. */
optimize?: boolean;
// @todo: This builder-only prop should not be part of the runtime component.
$webstudio$canvasOnly$assetId?: string;
};
export const getSdkImageProps = ({
props,
imageLoader,
renderer,
}: {
props: SdkImageProps;
imageLoader: ImageLoader;
renderer: "canvas" | "preview" | undefined;
}) => {
let {
loading = "lazy",
width,
height,
optimize = true,
decoding,
quality,
$webstudio$canvasOnly$assetId,
alt,
sizes,
srcSet,
src: source,
...imageProps
} = props;
const src = String(source ?? "");
let key = src;
if (renderer === "canvas") {
// With disabled cache and loading lazy, Chrome may not render the image.
loading = "eager";
// Avoid image flickering when switching from preview to an uploaded asset.
decoding = "sync";
key = $webstudio$canvasOnly$assetId ?? src;
// NaN dimensions mean the image is still uploading and cannot be optimized.
if (
width !== undefined &&
height !== undefined &&
Number.isNaN(width) &&
Number.isNaN(height)
) {
optimize = false;
width = undefined;
height = undefined;
}
}
return {
key,
imageProps: {
...imageProps,
...getImageAttributes({
alt,
width,
height,
sizes,
src,
srcSet,
quality,
loader: imageLoader,
optimize,
loading,
decoding,
}),
},
};
};
+11 -65
View File
@@ -1,67 +1,20 @@
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
useContext,
} from "react";
import { Image as WebstudioImage } from "@webstudio-is/image";
import { type ElementRef, forwardRef, useContext } from "react";
import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime";
import { getSdkImageProps, type SdkImageProps } from "./image-utils";
export const defaultTag = "img";
type Props = Omit<ComponentPropsWithoutRef<typeof WebstudioImage>, "loader">;
export const Image = forwardRef<
ElementRef<typeof defaultTag>,
Props & { $webstudio$canvasOnly$assetId?: string | undefined }
>(
(
{
loading = "lazy",
width,
height,
optimize = true,
decoding: decodingProp,
// @todo: it's a hack made for the builder and should't be in the runtime at all.
$webstudio$canvasOnly$assetId,
...props
},
ref
) => {
// cast to string when invalid value type is provided with binding
const src = String(props.src ?? "");
export const Image = forwardRef<ElementRef<typeof defaultTag>, SdkImageProps>(
(props, ref) => {
const { imageLoader, renderer } = useContext(ReactSdkContext);
let decoding = decodingProp;
let key = src;
if (renderer === "canvas") {
// With disabled cache and loading lazy, chrome may not render the image at all
loading = "eager";
// Avoid image flickering on switching from preview to asset (during upload)
decoding = "sync";
// use assetId as key to not recreate the image if it's switched from uploading to uploaded asset state (we don't know asset src during uploading)
key = $webstudio$canvasOnly$assetId ?? src;
// NaN width and height means that the image is not yet uploaded, and should not be optimized on canvas
if (
width !== undefined &&
height !== undefined &&
Number.isNaN(width) &&
Number.isNaN(height)
) {
optimize = false;
width = undefined;
height = undefined;
}
}
const { key, imageProps } = getSdkImageProps({
props,
imageLoader,
renderer,
});
return (
<WebstudioImage
<img
/**
* `key` is needed to recreate the image in case of asset change in builder,
* this gives immediate feedback when an asset is changed.
@@ -70,14 +23,7 @@ export const Image = forwardRef<
* prevents showing outdated images on route change.
**/
key={key}
loading={loading}
decoding={decoding}
optimize={optimize}
width={width}
height={height}
{...props}
loader={imageLoader}
src={src}
{...imageProps}
ref={ref}
/>
);
+4 -2
View File
@@ -47,13 +47,15 @@ const getCurrentUrl = (value: string | URL | undefined) => {
*/
export const BaseLink = forwardRef<
HTMLAnchorElement,
Props & { $webstudio$canvasOnly$assetId?: string | undefined }
Props & {
// @todo: This builder-only prop should not be part of the runtime component.
$webstudio$canvasOnly$assetId?: string | undefined;
}
>((props, ref) => {
const currentUrlValue = useContext(LinkCurrentUrlContext);
const { assetBaseUrl } = useContext(ReactSdkContext);
const {
children,
// @todo: it's a hack made for Image component for the builder and should't be in the runtime at all.
$webstudio$canvasOnly$assetId,
"aria-current": ariaCurrent,
className,
@@ -1,7 +1,33 @@
import { getImageAttributes, type ImageLoader } from "@webstudio-is/image";
import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime";
import { renderToStaticMarkup } from "react-dom/server";
import sanitizeHtml from "sanitize-html";
import { describe, expect, test } from "vitest";
import { Image } from "./image";
import { MarkdownEmbed } from "./markdown-embed";
const imageLoader: ImageLoader = (options) => {
if (options.format === "raw") {
return `/raw${options.src}`;
}
return `/optimized/${options.width}${options.src}`;
};
const readImageAttributes = (html: string) => {
let imageAttributes: Record<string, string> = {};
sanitizeHtml(html, {
allowedTags: ["img"],
allowedAttributes: false,
transformTags: {
img: (tagName, attributes) => {
imageAttributes = attributes;
return { tagName, attribs: attributes };
},
},
});
return imageAttributes;
};
describe("MarkdownEmbed", () => {
test("adds stable unique IDs to ATX and setext headings", () => {
const html = renderToStaticMarkup(
@@ -55,8 +81,10 @@ Hello, world!
'<iframe src="https://player.vimeo.com/video/123" title="Demo" allowfullscreen></iframe>'
);
expect(html).toContain("<figcaption>Demo video</figcaption>");
expect(html).toContain('alt="Safe image"');
expect(html).toContain('src="https://example.com/image.png"');
expect(html).toContain(
'<img src="https://example.com/image.png" alt="Safe image" />'
'srcset="https://example.com/image.png 384w, https://example.com/image.png 640w'
);
expect(html).not.toContain("<script");
expect(html).not.toContain("javascript:");
@@ -88,4 +116,79 @@ Hello, world!
expect(html).toContain('<h1 id="visible">Visible</h1>');
expect(html).not.toContain("title: Hidden");
});
test.each([
{
name: "published pages",
renderer: undefined,
loading: "lazy" as const,
decoding: "async" as const,
},
{
name: "the canvas",
renderer: "canvas" as const,
loading: "eager" as const,
decoding: "sync" as const,
},
])(
"renders images like the SDK Image component on $name",
({ renderer, loading, decoding }) => {
const context = {
assetBaseUrl: "/",
imageLoader,
resources: {},
breakpoints: [],
onError: () => {},
renderer,
};
const markdownHtml = renderToStaticMarkup(
<ReactSdkContext.Provider value={context}>
<MarkdownEmbed
code={`<img src="/photo.jpg" alt="Photo" width="320" height="180" class="hero">`}
/>
</ReactSdkContext.Provider>
);
const sdkHtml = renderToStaticMarkup(
<ReactSdkContext.Provider value={context}>
<div>
<Image
src="/photo.jpg"
alt="Photo"
width={320}
height={180}
className="hero"
/>
</div>
</ReactSdkContext.Provider>
);
const optimizedImageHtml = renderToStaticMarkup(
<div>
<img
className="hero"
{...getImageAttributes({
src: "/photo.jpg",
alt: "Photo",
width: 320,
height: 180,
loader: imageLoader,
loading,
decoding,
})}
/>
</div>
);
expect(readImageAttributes(markdownHtml)).toEqual(
readImageAttributes(sdkHtml)
);
expect(readImageAttributes(markdownHtml)).toEqual(
readImageAttributes(optimizedImageHtml)
);
expect(markdownHtml).toContain(`loading="${loading}"`);
expect(markdownHtml).toContain(`decoding="${decoding}"`);
expect(markdownHtml.indexOf(" srcset=")).toBeLessThan(
markdownHtml.indexOf(" src=")
);
}
);
});
@@ -1,4 +1,5 @@
import { forwardRef, useMemo, type ComponentProps } from "react";
import { forwardRef, useContext, useMemo, type ComponentProps } from "react";
import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime";
import { renderMarkdownHtml } from "./markdown";
type MarkdownEmbedProps = ComponentProps<"div"> & {
@@ -12,10 +13,11 @@ export const MarkdownEmbed = /* @__PURE__ */ forwardRef<
MarkdownEmbedProps
>((props, ref) => {
const { code, children, ...rest } = props;
const { imageLoader, renderer } = useContext(ReactSdkContext);
const html = useMemo(
// support data uri protocol in images
() => renderMarkdownHtml(code ?? ""),
[code]
() => renderMarkdownHtml(code ?? "", { imageLoader, renderer }),
[code, imageLoader, renderer]
);
return <div {...rest} ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
});
+87 -5
View File
@@ -7,6 +7,8 @@ import { toString } from "mdast-util-to-string";
import GithubSlugger from "github-slugger";
import type { HtmlExtension } from "micromark-util-types";
import sanitizeHtml from "sanitize-html";
import type { ImageLoader } from "@webstudio-is/image";
import { getSdkImageProps, type SdkImageProps } from "./image-utils";
const getHeadingIds = (markdown: string) => {
const slugger = new GithubSlugger();
@@ -66,7 +68,13 @@ const createHeadingIdsHtmlExtension = (
const sanitizeMarkdownHtml = (
html: string,
{ allowBlobImages }: { allowBlobImages: boolean }
{
allowBlobImages,
transformTags,
}: {
allowBlobImages: boolean;
transformTags?: sanitizeHtml.IOptions["transformTags"];
}
) =>
sanitizeHtml(html, {
allowedTags: [
@@ -106,7 +114,16 @@ const sanitizeMarkdownHtml = (
"src",
"width",
],
img: ["alt", "height", "loading", "sizes", "src", "srcset", "width"],
img: [
"alt",
"decoding",
"height",
"loading",
"sizes",
"src",
"srcset",
"width",
],
input: ["checked", "disabled", "type"],
source: ["media", "sizes", "src", "srcset", "type"],
video: [
@@ -130,11 +147,53 @@ const sanitizeMarkdownHtml = (
video: ["data", "http", "https"],
},
allowProtocolRelative: false,
transformTags,
exclusiveFilter: ({ tag, attribs }) =>
tag === "input" &&
(attribs.type !== "checkbox" || attribs.disabled === undefined),
});
const createImageTransformer =
({
imageLoader,
renderer,
}: {
imageLoader: ImageLoader;
renderer: "canvas" | "preview" | undefined;
}): sanitizeHtml.Transformer =>
(tagName, attributes) => {
const { srcset, ...rest } = attributes;
const { imageProps } = getSdkImageProps({
props: {
...rest,
srcSet: srcset,
loading: attributes.loading as SdkImageProps["loading"],
decoding: attributes.decoding as SdkImageProps["decoding"],
} as SdkImageProps,
imageLoader,
renderer,
});
const { sizes, srcSet, src, decoding, loading, ...htmlProps } = imageProps;
const transformedAttributes: sanitizeHtml.Attributes = {};
for (const [name, value] of Object.entries(htmlProps)) {
if (typeof value === "string" || typeof value === "number") {
transformedAttributes[name] = String(value);
}
}
if (sizes !== undefined) {
transformedAttributes.sizes = String(sizes);
}
if (srcSet !== undefined) {
transformedAttributes.srcset = String(srcSet);
}
// Keep src after sizes and srcset so Safari does not load the image twice.
transformedAttributes.src = String(src);
transformedAttributes.decoding = String(decoding);
transformedAttributes.loading = String(loading);
return { tagName, attribs: transformedAttributes };
};
const createGfmHtmlExtension = () => {
const extension = gfmHtml();
// GFM tag filtering escapes embedded HTML before our stricter sanitizer can
@@ -148,9 +207,17 @@ const createGfmHtmlExtension = () => {
/** Shared safe renderer used by both the authoring preview and published component. */
export const renderMarkdownHtml = (
markdown: string,
{ allowBlobImages = false }: { allowBlobImages?: boolean } = {}
) =>
sanitizeMarkdownHtml(
{
allowBlobImages = false,
imageLoader,
renderer,
}: {
allowBlobImages?: boolean;
imageLoader?: ImageLoader;
renderer?: "canvas" | "preview";
} = {}
) => {
const html = sanitizeMarkdownHtml(
micromark(markdown, {
allowDangerousHtml: true,
allowDangerousProtocol: true,
@@ -162,3 +229,18 @@ export const renderMarkdownHtml = (
}),
{ allowBlobImages }
);
if (imageLoader === undefined) {
return html;
}
// Sanitize before transforming so unsafe sources cannot become valid URLs
// after passing through the image loader. Sanitize the generated attributes
// again because loaders are application-provided.
return sanitizeMarkdownHtml(html, {
allowBlobImages,
transformTags: {
img: createImageTransformer({ imageLoader, renderer }),
},
});
};
@@ -29,6 +29,7 @@ export const Video = forwardRef<
$visible?: Atom<boolean>;
$timeline?: boolean;
} & {
// @todo: This builder-only prop should not be part of the runtime component.
$webstudio$canvasOnly$assetId?: string | undefined;
}
>(
+3 -3
View File
@@ -2013,9 +2013,6 @@ importers:
'@webstudio-is/sdk':
specifier: workspace:*
version: link:../sdk
react:
specifier: 18.3.0-canary-14898b6a9-20240318
version: 18.3.0-canary-14898b6a9-20240318
warn-once:
specifier: ^0.1.1
version: 0.1.1
@@ -2032,6 +2029,9 @@ importers:
'@webstudio-is/tsconfig':
specifier: workspace:*
version: link:../tsconfig
react:
specifier: 18.3.0-canary-14898b6a9-20240318
version: 18.3.0-canary-14898b6a9-20240318
react-dom:
specifier: 18.3.0-canary-14898b6a9-20240318
version: 18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318)