Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions admin/scripts/resizeImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {fileURLToPath} from 'url';
import {program} from 'commander';
import {logger} from '@docusaurus/logger';
import sharp from 'sharp';
import {imageSizeFromFile} from 'image-size/fromFile';
import {imageDimensionsFromData} from 'image-dimensions';

// You can use it as:
//
Expand Down Expand Up @@ -64,7 +64,11 @@ program

await Promise.all(
images.map(async (imgPath) => {
const {width, height} = await imageSizeFromFile(imgPath);
const dimensions = imageDimensionsFromData(await fs.readFile(imgPath));
if (!dimensions) {
throw new Error(`Could not parse image size for ${imgPath}`);
}
const {width, height} = dimensions;
const targetWidth =
options.width ?? (imgPath.includes(showcasePath) ? 640 : 1000);
const targetHeight =
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"execa": "^10.0.0",
"globals": "^17.6.0",
"husky": "^9.1.7",
"image-size": "^2.0.2",
"image-dimensions": "^2.5.1",
"jest-serializer-ansi-escapes": "^5.0.0",
"jest-serializer-react-helmet-async": "^1.0.21",
"jiti": "^2.7.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/docusaurus-mdx-loader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"estree-util-value-to-estree": "^3.5.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.2.0",
"image-size": "^2.0.2",
"image-dimensions": "^2.5.1",
"mdast-util-mdx": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"rehype-raw": "^7.0.0",
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-mdx-loader/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ async function createProcessorFactory() {
siteDir: options.siteDir,
onBrokenMarkdownImages:
options.markdownConfig.hooks.onBrokenMarkdownImages,
measureImage: options.markdownConfig.hooks.measureImage,
} satisfies TransformImageOptions,
],
// TODO merge this with transformLinks?
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -365,4 +365,18 @@ describe('transformImage plugin', () => {
});
});
});

it('adds width/height for SVG images', async () => {
const result = await processContent(`![icon](/icon.svg)`);
expect(result).toContain('width="24"');
expect(result).toContain('height="16"');
});

it('uses markdown.hooks.measureImage when provided', async () => {
const result = await processContent(`![img](/img.png)`, {
measureImage: async () => ({width: 11, height: 22}),
});
expect(result).toContain('width="11"');
expect(result).toContain('height="22"');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {describe, expect, it} from 'vitest';
import {measureSvg} from '../measureImage';

describe('measureSvg', () => {
it('reads width and height attributes', () => {
const svg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="16"></svg>`,
);
expect(measureSvg(svg)).toEqual({width: 24, height: 16});
});

it('falls back to viewBox when width/height are percentages', () => {
const svg = Buffer.from(
`<svg viewBox="0 0 100 50" width="100%" height="100%"></svg>`,
);
expect(measureSvg(svg)).toEqual({width: 100, height: 50});
});

it('returns null for non-svg content', () => {
expect(measureSvg(Buffer.from('not an image'))).toBeNull();
});
});
43 changes: 18 additions & 25 deletions packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,35 @@ import {
parseLocalURLPath,
} from '@docusaurus/utils';
import escapeHtml from 'escape-html';
import {imageSizeFromFile} from 'image-size/fromFile';
import logger from '@docusaurus/logger';
import {
assetRequireAttributeValue,
formatNodePositionExtraMessage,
transformNode,
} from '../utils';
import {readImageSize} from './measureImage';
import type {Plugin, Transformer} from 'unified';
import type {MdxJsxTextElement} from 'mdast-util-mdx';
import type {Image, Root} from 'mdast';
import type {Parent} from 'unist';
import type {
MarkdownConfig,
MeasureImageFunction,
OnBrokenMarkdownImagesFunction,
} from '@docusaurus/types';

export type PluginOptions = {
staticDirs: string[];
siteDir: string;
onBrokenMarkdownImages: MarkdownConfig['hooks']['onBrokenMarkdownImages'];
measureImage?: MeasureImageFunction;
};

type Context = {
staticDirs: PluginOptions['staticDirs'];
siteDir: PluginOptions['siteDir'];
onBrokenMarkdownImages: OnBrokenMarkdownImagesFunction;
measureImage?: MeasureImageFunction;
filePath: string;
inlineMarkdownImageFileLoader: string;
};
Expand Down Expand Up @@ -124,30 +127,20 @@ async function toImageRequireNode(
});
}

try {
const size = (await imageSizeFromFile(imagePath))!;
if (size.width) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'width',
value: String(size.width),
});
}
if (size.height) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'height',
value: String(size.height),
});
}
} catch (err) {
console.error(err);
// Workaround for https://github.com/yarnpkg/berry/pull/3889#issuecomment-1034469784
// TODO remove this check once fixed in Yarn PnP
if (!process.versions.pnp) {
logger.warn`The image at path=${imagePath} can't be read correctly. Please ensure it's a valid image.
${(err as Error).message}`;
}
const size = await readImageSize(imagePath, context.measureImage);
if (size?.width) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'width',
value: String(size.width),
});
}
if (size?.height) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'height',
value: String(size.height),
});
}

transformNode(jsxNode, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import path from 'path';
import fs from 'fs-extra';
import {imageDimensionsFromData} from 'image-dimensions';
import logger from '@docusaurus/logger';
import type {MeasureImageFunction, MeasureImageSize} from '@docusaurus/types';

function parseLength(value: string | undefined): number | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (trimmed.endsWith('%')) {
return undefined;
}
const parsed = Number.parseFloat(trimmed);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}

function getSvgAttribute(attributes: string, name: string): string | undefined {
const match = attributes.match(
new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'),
);
return match?.[1] ?? match?.[2] ?? match?.[3];
}

export function measureSvg(buffer: Uint8Array): MeasureImageSize | null {
const text = Buffer.from(buffer).toString('utf8');
const svgTag = text.match(/<svg\b([^>]*)>/i);
if (!svgTag) {
return null;
}
const attributes = svgTag[1] ?? '';
const width = parseLength(getSvgAttribute(attributes, 'width'));
const height = parseLength(getSvgAttribute(attributes, 'height'));
if (width !== undefined && height !== undefined) {
return {width, height};
}
const viewBox = getSvgAttribute(attributes, 'viewBox');
if (viewBox) {
const parts = viewBox.trim().split(/[\s,]+/);
const viewBoxWidth = Number.parseFloat(parts[2] ?? '');
const viewBoxHeight = Number.parseFloat(parts[3] ?? '');
if (
Number.isFinite(viewBoxWidth) &&
Number.isFinite(viewBoxHeight) &&
viewBoxWidth > 0 &&
viewBoxHeight > 0
) {
return {
width: width ?? viewBoxWidth,
height: height ?? viewBoxHeight,
};
}
}
if (width !== undefined || height !== undefined) {
return {width, height};
}
return null;
}

function looksLikeSvg(buffer: Uint8Array, imagePath: string): boolean {
if (path.extname(imagePath).toLowerCase() === '.svg') {
return true;
}
const head = Buffer.from(buffer.subarray(0, 256))
.toString('utf8')
.trimStart();
return (
head.startsWith('<svg') ||
(head.startsWith('<?xml') && head.toLowerCase().includes('<svg'))
);
}

function logUnreadableImage(imagePath: string, err?: unknown): void {
// Workaround for https://github.com/yarnpkg/berry/pull/3889#issuecomment-1034469784
// TODO remove this check once fixed in Yarn PnP
if (process.versions.pnp) {
return;
}
if (err) {
console.error(err);
}
const extra = err instanceof Error ? err.message : '';
logger.warn`The image at path=${imagePath} can't be read correctly. Please ensure it's a valid image.
${extra}`;
}

export const defaultMeasureImage: MeasureImageFunction = async ({
imagePath,
}) => {
try {
const buffer = await fs.readFile(imagePath);
const size = looksLikeSvg(buffer, imagePath)
? measureSvg(buffer)
: imageDimensionsFromData(buffer);
if (!size?.width && !size?.height) {
logUnreadableImage(imagePath);
return null;
}
return {
...(size.width ? {width: size.width} : {}),
...(size.height ? {height: size.height} : {}),
};
} catch (err) {
logUnreadableImage(imagePath, err);
return null;
}
};

export async function readImageSize(
imagePath: string,
measureImage: MeasureImageFunction = defaultMeasureImage,
): Promise<MeasureImageSize | null> {
try {
const size = await measureImage({imagePath});
if (!size?.width && !size?.height) {
return null;
}
return size;
} catch (err) {
logUnreadableImage(imagePath, err);
return null;
}
}
2 changes: 2 additions & 0 deletions packages/docusaurus-types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export {
OnBrokenMarkdownLinksFunction,
OnBrokenMarkdownImagesFunction,
OnUnusedMarkdownDirectivesFunction,
MeasureImageFunction,
MeasureImageSize,
} from './markdown';

export {ReportingSeverity} from './reporting';
Expand Down
21 changes: 21 additions & 0 deletions packages/docusaurus-types/src/markdown.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ export type OnUnusedMarkdownDirectivesFunction = (params: {
directives: Directives[];
}) => void | string;

export type MeasureImageSize = {
width?: number;
height?: number;
};

export type MeasureImageFunction = (params: {
/**
* Absolute path of the resolved local image file.
*/
imagePath: string;
}) => MeasureImageSize | null | Promise<MeasureImageSize | null>;

export type MarkdownHooks = {
/**
* The behavior of Docusaurus when it detects any broken Markdown link.
Expand All @@ -117,6 +129,15 @@ export type MarkdownHooks = {
onUnusedMarkdownDirectives:
| ReportingSeverity
| OnUnusedMarkdownDirectivesFunction;

/**
* Override how Markdown images are measured at build time (width/height
* attributes). When omitted, Docusaurus measures SVG locally and other
* common raster formats via `image-dimensions`.
*
* Return `null` to skip emitting width/height.
*/
measureImage?: MeasureImageFunction;
};

export type MarkdownConfig = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,34 @@ describe('markdown', () => {
`);
});
});

describe('measureImage', () => {
function normalizeValue(measureImage?: MarkdownHooks['measureImage']) {
return normalizeHooks({
measureImage,
}).measureImage;
}

it('accepts undefined', () => {
expect(normalizeValue(undefined)).toBeUndefined();
});

it('accepts function', () => {
expect(normalizeValue(() => null)).toBeInstanceOf(Function);
});

it('rejects number', () => {
expect(() =>
normalizeValue(
// @ts-expect-error: bad value
42,
),
).toThrowErrorMatchingInlineSnapshot(`
[Error: "markdown.hooks.measureImage" must be of type function
]
`);
});
});
});
});

Expand Down
Loading