Skip to content

Commit 6f97dc4

Browse files
committed
fix(mdx-loader): replace image-size with measureImage hook
image-size is archived and carries unpatched DoS CVEs. Measure SVG locally and raster formats via image-dimensions, with an optional markdown.hooks.measureImage escape hatch.
1 parent 27de99d commit 6f97dc4

16 files changed

Lines changed: 298 additions & 54 deletions

File tree

admin/scripts/resizeImage.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {fileURLToPath} from 'url';
1111
import {program} from 'commander';
1212
import {logger} from '@docusaurus/logger';
1313
import sharp from 'sharp';
14-
import {imageSizeFromFile} from 'image-size/fromFile';
14+
import {imageDimensionsFromData} from 'image-dimensions';
1515

1616
// You can use it as:
1717
//
@@ -64,7 +64,11 @@ program
6464

6565
await Promise.all(
6666
images.map(async (imgPath) => {
67-
const {width, height} = await imageSizeFromFile(imgPath);
67+
const dimensions = imageDimensionsFromData(await fs.readFile(imgPath));
68+
if (!dimensions) {
69+
throw new Error(`Could not parse image size for ${imgPath}`);
70+
}
71+
const {width, height} = dimensions;
6872
const targetWidth =
6973
options.width ?? (imgPath.includes(showcasePath) ? 640 : 1000);
7074
const targetHeight =

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@
105105
"eslint-plugin-regexp": "^3.1.0",
106106
"globals": "^17.6.0",
107107
"husky": "^9.1.7",
108-
"image-size": "^2.0.2",
108+
"image-dimensions": "^2.5.1",
109109
"jest-serializer-ansi-escapes": "^5.0.0",
110110
"jest-serializer-react-helmet-async": "^1.0.21",
111111
"jiti": "^2.7.0",

packages/docusaurus-mdx-loader/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"estree-util-value-to-estree": "^3.5.0",
2828
"file-loader": "^6.2.0",
2929
"fs-extra": "^11.2.0",
30-
"image-size": "^2.0.2",
30+
"image-dimensions": "^2.5.1",
3131
"mdast-util-mdx": "^3.0.0",
3232
"mdast-util-to-string": "^4.0.0",
3333
"rehype-raw": "^7.0.0",

packages/docusaurus-mdx-loader/src/processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ async function createProcessorFactory() {
127127
siteDir: options.siteDir,
128128
onBrokenMarkdownImages:
129129
options.markdownConfig.hooks.onBrokenMarkdownImages,
130+
measureImage: options.markdownConfig.hooks.measureImage,
130131
} satisfies TransformImageOptions,
131132
],
132133
// TODO merge this with transformLinks?

packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/icon.svg

Lines changed: 3 additions & 0 deletions
Loading

packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,18 @@ describe('transformImage plugin', () => {
365365
});
366366
});
367367
});
368+
369+
it('adds width/height for SVG images', async () => {
370+
const result = await processContent(`![icon](/icon.svg)`);
371+
expect(result).toContain('width="24"');
372+
expect(result).toContain('height="16"');
373+
});
374+
375+
it('uses markdown.hooks.measureImage when provided', async () => {
376+
const result = await processContent(`![img](/img.png)`, {
377+
measureImage: async () => ({width: 11, height: 22}),
378+
});
379+
expect(result).toContain('width="11"');
380+
expect(result).toContain('height="22"');
381+
});
368382
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {describe, expect, it} from 'vitest';
9+
import {measureSvg} from '../measureImage';
10+
11+
describe('measureSvg', () => {
12+
it('reads width and height attributes', () => {
13+
const svg = Buffer.from(
14+
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"></svg>`,
15+
);
16+
expect(measureSvg(svg)).toEqual({width: 24, height: 16});
17+
});
18+
19+
it('falls back to viewBox when width/height are percentages', () => {
20+
const svg = Buffer.from(
21+
`<svg viewBox="0 0 100 50" width="100%" height="100%"></svg>`,
22+
);
23+
expect(measureSvg(svg)).toEqual({width: 100, height: 50});
24+
});
25+
26+
it('returns null for non-svg content', () => {
27+
expect(measureSvg(Buffer.from('not an image'))).toBeNull();
28+
});
29+
});

packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,35 @@ import {
1717
parseLocalURLPath,
1818
} from '@docusaurus/utils';
1919
import escapeHtml from 'escape-html';
20-
import {imageSizeFromFile} from 'image-size/fromFile';
2120
import logger from '@docusaurus/logger';
2221
import {
2322
assetRequireAttributeValue,
2423
formatNodePositionExtraMessage,
2524
transformNode,
2625
} from '../utils';
26+
import {readImageSize} from './measureImage';
2727
import type {Plugin, Transformer} from 'unified';
2828
import type {MdxJsxTextElement} from 'mdast-util-mdx';
2929
import type {Image, Root} from 'mdast';
3030
import type {Parent} from 'unist';
3131
import type {
3232
MarkdownConfig,
33+
MeasureImageFunction,
3334
OnBrokenMarkdownImagesFunction,
3435
} from '@docusaurus/types';
3536

3637
export type PluginOptions = {
3738
staticDirs: string[];
3839
siteDir: string;
3940
onBrokenMarkdownImages: MarkdownConfig['hooks']['onBrokenMarkdownImages'];
41+
measureImage?: MeasureImageFunction;
4042
};
4143

4244
type Context = {
4345
staticDirs: PluginOptions['staticDirs'];
4446
siteDir: PluginOptions['siteDir'];
4547
onBrokenMarkdownImages: OnBrokenMarkdownImagesFunction;
48+
measureImage?: MeasureImageFunction;
4649
filePath: string;
4750
inlineMarkdownImageFileLoader: string;
4851
};
@@ -124,30 +127,20 @@ async function toImageRequireNode(
124127
});
125128
}
126129

127-
try {
128-
const size = (await imageSizeFromFile(imagePath))!;
129-
if (size.width) {
130-
attributes.push({
131-
type: 'mdxJsxAttribute',
132-
name: 'width',
133-
value: String(size.width),
134-
});
135-
}
136-
if (size.height) {
137-
attributes.push({
138-
type: 'mdxJsxAttribute',
139-
name: 'height',
140-
value: String(size.height),
141-
});
142-
}
143-
} catch (err) {
144-
console.error(err);
145-
// Workaround for https://github.com/yarnpkg/berry/pull/3889#issuecomment-1034469784
146-
// TODO remove this check once fixed in Yarn PnP
147-
if (!process.versions.pnp) {
148-
logger.warn`The image at path=${imagePath} can't be read correctly. Please ensure it's a valid image.
149-
${(err as Error).message}`;
150-
}
130+
const size = await readImageSize(imagePath, context.measureImage);
131+
if (size?.width) {
132+
attributes.push({
133+
type: 'mdxJsxAttribute',
134+
name: 'width',
135+
value: String(size.width),
136+
});
137+
}
138+
if (size?.height) {
139+
attributes.push({
140+
type: 'mdxJsxAttribute',
141+
name: 'height',
142+
value: String(size.height),
143+
});
151144
}
152145

153146
transformNode(jsxNode, {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import path from 'path';
9+
import fs from 'fs-extra';
10+
import {imageDimensionsFromData} from 'image-dimensions';
11+
import logger from '@docusaurus/logger';
12+
import type {MeasureImageFunction, MeasureImageSize} from '@docusaurus/types';
13+
14+
function parseLength(value: string | undefined): number | undefined {
15+
if (!value) {
16+
return undefined;
17+
}
18+
const trimmed = value.trim();
19+
if (trimmed.endsWith('%')) {
20+
return undefined;
21+
}
22+
const parsed = Number.parseFloat(trimmed);
23+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
24+
}
25+
26+
function getSvgAttribute(attributes: string, name: string): string | undefined {
27+
const match = attributes.match(
28+
new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'),
29+
);
30+
return match?.[1] ?? match?.[2] ?? match?.[3];
31+
}
32+
33+
export function measureSvg(buffer: Uint8Array): MeasureImageSize | null {
34+
const text = Buffer.from(buffer).toString('utf8');
35+
const svgTag = text.match(/<svg\b([^>]*)>/i);
36+
if (!svgTag) {
37+
return null;
38+
}
39+
const attributes = svgTag[1] ?? '';
40+
const width = parseLength(getSvgAttribute(attributes, 'width'));
41+
const height = parseLength(getSvgAttribute(attributes, 'height'));
42+
if (width !== undefined && height !== undefined) {
43+
return {width, height};
44+
}
45+
const viewBox = getSvgAttribute(attributes, 'viewBox');
46+
if (viewBox) {
47+
const parts = viewBox.trim().split(/[\s,]+/);
48+
const viewBoxWidth = Number.parseFloat(parts[2] ?? '');
49+
const viewBoxHeight = Number.parseFloat(parts[3] ?? '');
50+
if (
51+
Number.isFinite(viewBoxWidth) &&
52+
Number.isFinite(viewBoxHeight) &&
53+
viewBoxWidth > 0 &&
54+
viewBoxHeight > 0
55+
) {
56+
return {
57+
width: width ?? viewBoxWidth,
58+
height: height ?? viewBoxHeight,
59+
};
60+
}
61+
}
62+
if (width !== undefined || height !== undefined) {
63+
return {width, height};
64+
}
65+
return null;
66+
}
67+
68+
function looksLikeSvg(buffer: Uint8Array, imagePath: string): boolean {
69+
if (path.extname(imagePath).toLowerCase() === '.svg') {
70+
return true;
71+
}
72+
const head = Buffer.from(buffer.subarray(0, 256))
73+
.toString('utf8')
74+
.trimStart();
75+
return (
76+
head.startsWith('<svg') ||
77+
(head.startsWith('<?xml') && head.toLowerCase().includes('<svg'))
78+
);
79+
}
80+
81+
function logUnreadableImage(imagePath: string, err?: unknown): void {
82+
// Workaround for https://github.com/yarnpkg/berry/pull/3889#issuecomment-1034469784
83+
// TODO remove this check once fixed in Yarn PnP
84+
if (process.versions.pnp) {
85+
return;
86+
}
87+
if (err) {
88+
console.error(err);
89+
}
90+
const extra = err instanceof Error ? err.message : '';
91+
logger.warn`The image at path=${imagePath} can't be read correctly. Please ensure it's a valid image.
92+
${extra}`;
93+
}
94+
95+
export const defaultMeasureImage: MeasureImageFunction = async ({
96+
imagePath,
97+
}) => {
98+
try {
99+
const buffer = await fs.readFile(imagePath);
100+
const size = looksLikeSvg(buffer, imagePath)
101+
? measureSvg(buffer)
102+
: imageDimensionsFromData(buffer);
103+
if (!size?.width && !size?.height) {
104+
logUnreadableImage(imagePath);
105+
return null;
106+
}
107+
return {
108+
...(size.width ? {width: size.width} : {}),
109+
...(size.height ? {height: size.height} : {}),
110+
};
111+
} catch (err) {
112+
logUnreadableImage(imagePath, err);
113+
return null;
114+
}
115+
};
116+
117+
export async function readImageSize(
118+
imagePath: string,
119+
measureImage: MeasureImageFunction = defaultMeasureImage,
120+
): Promise<MeasureImageSize | null> {
121+
try {
122+
const size = await measureImage({imagePath});
123+
if (!size?.width && !size?.height) {
124+
return null;
125+
}
126+
return size;
127+
} catch (err) {
128+
logUnreadableImage(imagePath, err);
129+
return null;
130+
}
131+
}

packages/docusaurus-types/src/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export {
2929
OnBrokenMarkdownLinksFunction,
3030
OnBrokenMarkdownImagesFunction,
3131
OnUnusedMarkdownDirectivesFunction,
32+
MeasureImageFunction,
33+
MeasureImageSize,
3234
} from './markdown';
3335

3436
export {ReportingSeverity} from './reporting';

0 commit comments

Comments
 (0)