mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
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:
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
|
||||
|
||||
+16
-12
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 }} />;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
>(
|
||||
|
||||
Generated
+3
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user