diff --git a/admin/scripts/resizeImage.js b/admin/scripts/resizeImage.js index 09bf4f4ee535..45fe0a30aac0 100644 --- a/admin/scripts/resizeImage.js +++ b/admin/scripts/resizeImage.js @@ -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: // @@ -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 = diff --git a/package.json b/package.json index 261e57d8722c..a05745935b0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/docusaurus-mdx-loader/package.json b/packages/docusaurus-mdx-loader/package.json index 52476b7c3bdb..3d2714dc1fe8 100644 --- a/packages/docusaurus-mdx-loader/package.json +++ b/packages/docusaurus-mdx-loader/package.json @@ -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", diff --git a/packages/docusaurus-mdx-loader/src/processor.ts b/packages/docusaurus-mdx-loader/src/processor.ts index 0035ab66c799..bf369e1913f0 100644 --- a/packages/docusaurus-mdx-loader/src/processor.ts +++ b/packages/docusaurus-mdx-loader/src/processor.ts @@ -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? diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/icon.svg b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/icon.svg new file mode 100644 index 000000000000..b4785418a9d1 --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts index 4602e27c76d9..1b39e790af22 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts @@ -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"'); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/measureImage.test.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/measureImage.test.ts new file mode 100644 index 000000000000..e9d192bc043b --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/measureImage.test.ts @@ -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( + ``, + ); + expect(measureSvg(svg)).toEqual({width: 24, height: 16}); + }); + + it('falls back to viewBox when width/height are percentages', () => { + const svg = Buffer.from( + ``, + ); + expect(measureSvg(svg)).toEqual({width: 100, height: 50}); + }); + + it('returns null for non-svg content', () => { + expect(measureSvg(Buffer.from('not an image'))).toBeNull(); + }); +}); diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts index ff88403eefb1..540a10a18aa2 100644 --- a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts @@ -17,19 +17,20 @@ 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'; @@ -37,12 +38,14 @@ 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; }; @@ -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, { diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/measureImage.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/measureImage.ts new file mode 100644 index 000000000000..96d57c7945fd --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/measureImage.ts @@ -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(/]*)>/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(' { + 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 { + try { + const size = await measureImage({imagePath}); + if (!size?.width && !size?.height) { + return null; + } + return size; + } catch (err) { + logUnreadableImage(imagePath, err); + return null; + } +} diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 6cdd2f839bb1..910cc78c8bce 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -29,6 +29,8 @@ export { OnBrokenMarkdownLinksFunction, OnBrokenMarkdownImagesFunction, OnUnusedMarkdownDirectivesFunction, + MeasureImageFunction, + MeasureImageSize, } from './markdown'; export {ReportingSeverity} from './reporting'; diff --git a/packages/docusaurus-types/src/markdown.d.ts b/packages/docusaurus-types/src/markdown.d.ts index 7c2d84cd3fdf..457cfc1b9e5d 100644 --- a/packages/docusaurus-types/src/markdown.d.ts +++ b/packages/docusaurus-types/src/markdown.d.ts @@ -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; + export type MarkdownHooks = { /** * The behavior of Docusaurus when it detects any broken Markdown link. @@ -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 = { diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index 3f635fb0d1b6..cc462eed07c8 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -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 + ] + `); + }); + }); }); }); diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 855d4119073e..d33f7518da6b 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -558,6 +558,7 @@ export const ConfigSchema = Joi.object({ Joi.function(), ) .default(DEFAULT_CONFIG.markdown.hooks.onUnusedMarkdownDirectives), + measureImage: Joi.function().optional(), }).default(DEFAULT_CONFIG.markdown.hooks), }).default({ ...DEFAULT_CONFIG.markdown, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea50188ac178..71af6928a185 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,9 +112,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 - image-size: - specifier: ^2.0.2 - version: 2.0.2 + image-dimensions: + specifier: ^2.5.1 + version: 2.5.1 jest-serializer-ansi-escapes: specifier: ^5.0.0 version: 5.0.0 @@ -746,9 +746,9 @@ importers: fs-extra: specifier: ^11.2.0 version: 11.3.5 - image-size: - specifier: ^2.0.2 - version: 2.0.2 + image-dimensions: + specifier: ^2.5.1 + version: 2.5.1 mdast-util-mdx: specifier: ^3.0.0 version: 3.0.0 @@ -8636,7 +8636,7 @@ packages: git-raw-commits@3.0.0: resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -8646,7 +8646,7 @@ packages: git-semver-tags@5.0.1: resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-up@7.0.0: @@ -9050,14 +9050,14 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-dimensions@2.5.1: + resolution: {integrity: sha512-It7CkTNp7IYA8jhvGgz8K18OAbHF3/Juk8RNEyTyMFfolJOIU1Y2wGDnzDQPbGCjyxVF/++pwIZE0BKJUEOb1g==} + engines: {node: '>=18'} + hasBin: true + image-q@4.0.0: resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} - image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -13772,7 +13772,7 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 - '@argos-ci/api-client@0.31.0': + '@argos-ci/api-client@0.31.0(supports-color@7.2.0)': dependencies: debug: 4.4.3(supports-color@7.2.0) openapi-fetch: 0.17.0 @@ -13796,7 +13796,7 @@ snapshots: '@argos-ci/core@6.8.1(@types/node@25.9.1)(supports-color@7.2.0)': dependencies: - '@argos-ci/api-client': 0.31.0 + '@argos-ci/api-client': 0.31.0(supports-color@7.2.0) '@argos-ci/util': 4.1.0 convict: 6.2.5 debug: 4.4.3(supports-color@7.2.0) @@ -21415,12 +21415,12 @@ snapshots: ignore@7.0.5: {} + image-dimensions@2.5.1: {} + image-q@4.0.0: dependencies: '@types/node': 16.9.1 - image-size@2.0.2: {} - import-fresh@3.3.1: dependencies: parent-module: 1.0.1 diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 339a6ede1f1d..54b9c1544645 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -675,6 +675,13 @@ type OnUnusedMarkdownDirectivesFunction = (params: { directives: Directives[]; // mdast nodes }) => void | string; +type MeasureImageFunction = (params: { + imagePath: string; // absolute path of the resolved local image +}) => + | {width?: number; height?: number} + | null + | Promise<{width?: number; height?: number} | null>; + type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw'; type MarkdownHooks = { @@ -683,6 +690,7 @@ type MarkdownHooks = { onUnusedMarkdownDirectives: | ReportingSeverity | OnUnusedMarkdownDirectivesFunction; + measureImage?: MeasureImageFunction; }; type MarkdownConfig = { @@ -727,6 +735,7 @@ export default { onBrokenMarkdownLinks: 'warn', onBrokenMarkdownImages: 'throw', onUnusedMarkdownDirectives: 'warn', + // measureImage: async ({imagePath}) => ({width: 16, height: 16}), }, }, }; @@ -750,6 +759,7 @@ export default { | `hooks.onBrokenMarkdownLinks` | `ReportingSeverity \| OnBrokenMarkdownLinksFunction` | `'warn'` | Hook to customize the behavior when encountering a broken Markdown link URL. With the callback function, you can return a new link URL, or alter the link [mdast node](https://github.com/syntax-tree/mdast). | | `hooks.onBrokenMarkdownImages` | `ReportingSeverity \| OnBrokenMarkdownImagesFunction` | `'throw'` | Hook to customize the behavior when encountering a broken Markdown image URL. With the callback function, you can return a new image URL, or alter the image [mdast node](https://github.com/syntax-tree/mdast). | | `hooks.onUnusedMarkdownDirectives` | `ReportingSeverity \| OnUnusedMarkdownDirectivesFunction` | `'warn'` | Hook to customize the behavior when encountering an unused [Markdown directive](https://github.com/remarkjs/remark-directive). Also accepts a callback function, giving you access to the raw `Directive` AST nodes. | +| `hooks.measureImage` | `MeasureImageFunction` | `undefined` | Override how local Markdown images are measured at build time (`width`/`height` attributes). The default measures SVG via a local parser and common raster formats (JPEG/PNG/GIF/WebP/AVIF/HEIF) via [`image-dimensions`](https://github.com/sindresorhus/image-dimensions). Return `null` to skip emitting dimensions. Formats previously handled only by `image-size` (BMP/ICO/TIFF/PSD/JXL/ICNS) are no longer auto-measured unless you provide this hook. | ```mdx-code-block diff --git a/website/src/data/__tests__/user.test.ts b/website/src/data/__tests__/user.test.ts index ca1e7e32e06e..c6f1dc6e0c54 100644 --- a/website/src/data/__tests__/user.test.ts +++ b/website/src/data/__tests__/user.test.ts @@ -9,7 +9,7 @@ import {describe, expect, it} from 'vitest'; import fs from 'fs-extra'; import path from 'path'; import _ from 'lodash'; -import {imageSizeFromFile} from 'image-size/fromFile'; +import {imageDimensionsFromData} from 'image-dimensions'; import {Joi} from '@docusaurus/utils-validation'; import {TagList, sortedUsers, type User} from '../users'; @@ -128,7 +128,14 @@ describe('preview images have good dimensions', () => { .filter((file) => ['.png', 'jpg', '.jpeg'].includes(path.extname(file))); it.each(files)('%s', async (file: string) => { - const {width, height} = await imageSizeFromFile(path.join(imageDir, file)); + const buffer = await fs.readFile(path.join(imageDir, file)); + const dimensions = imageDimensionsFromData(buffer); + if (!dimensions) { + throw new Error( + `Could not parse showcase image size for showcase ${file}`, + ); + } + const {width, height} = dimensions; const aspectRatio = width / height; expect( aspectRatio,