From 3868b64e419223279eaae800766b244cde9bc85f Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Wed, 15 Jul 2026 00:46:05 +0800 Subject: [PATCH] feat: auto-add width/height to local images to avoid layout shift (#5311) Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> --- __tests__/e2e/assets/vitepress logo.png | Bin 0 -> 2286 bytes __tests__/e2e/assets/vitepress.png | Bin 0 -> 2286 bytes .../unit/node/markdown/plugins/image.test.ts | 154 +++++++++++++++--- __tests__/unit/node/markdownToVue.test.ts | 12 +- .../node/plugins/localSearchPlugin.test.ts | 14 +- docs/.vitepress/theme/styles.css | 6 - package.json | 1 + pnpm-lock.yaml | 10 ++ src/node/build/bundle.ts | 4 +- src/node/config.ts | 9 + src/node/contentLoader.ts | 3 +- src/node/markdown/markdown.ts | 8 +- src/node/markdown/plugins/image.ts | 86 +++++++++- src/node/markdownToVue.ts | 14 +- src/node/plugin.ts | 6 +- src/node/plugins/localSearchPlugin.ts | 18 +- src/node/siteConfig.ts | 1 + 17 files changed, 278 insertions(+), 68 deletions(-) create mode 100644 __tests__/e2e/assets/vitepress logo.png create mode 100644 __tests__/e2e/assets/vitepress.png diff --git a/__tests__/e2e/assets/vitepress logo.png b/__tests__/e2e/assets/vitepress logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb0b38438a72f5551e0943af7435d657d3df13a GIT binary patch literal 2286 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 IM6N<$f;9JAwg3PC literal 0 HcmV?d00001 diff --git a/__tests__/e2e/assets/vitepress.png b/__tests__/e2e/assets/vitepress.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb0b38438a72f5551e0943af7435d657d3df13a GIT binary patch literal 2286 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 IM6N<$f;9JAwg3PC literal 0 HcmV?d00001 diff --git a/__tests__/unit/node/markdown/plugins/image.test.ts b/__tests__/unit/node/markdown/plugins/image.test.ts index 416ea785d..c2b56059d 100644 --- a/__tests__/unit/node/markdown/plugins/image.test.ts +++ b/__tests__/unit/node/markdown/plugins/image.test.ts @@ -1,33 +1,141 @@ +import path from 'node:path' import { MarkdownItAsync } from 'markdown-it-async' -import { imagePlugin } from 'node/markdown/plugins/image' +import attrsPlugin from 'markdown-it-attrs' +import { imagePlugin, type Options } from 'node/markdown/plugins/image' -describe('node/markdown/plugins/image', () => { +const srcDir = path.resolve(import.meta.dirname, '../../../../e2e') +const publicDir = path.join(srcDir, 'public') +const env = { path: path.join(srcDir, 'index.md') } + +function createRenderer(options?: Options) { const md = new MarkdownItAsync() - imagePlugin(md as any) - test('default image output', async () => { - const html = await md.renderAsync('![logo](foo.png)') - expect(html.trim()).toMatchInlineSnapshot( - `"

logo

"` - ) + // same registration order as createMarkdownRenderer + imagePlugin(md, publicDir, options) + attrsPlugin(md as any) + + return md +} + +describe('node/markdown/plugins/image', () => { + const md = createRenderer() + + describe('src normalization', () => { + test('default image output', async () => { + const html = await md.renderAsync('![logo](foo.png)') + + expect(html.trim()).toMatchInlineSnapshot( + `"

logo

"` + ) + }) + + test.for([ + ['foo.png', './foo.png'], + ['./foo.png', './foo.png'], + ['../foo.png', '../foo.png'], + ['../../foo.png', '../../foo.png'], + ['/foo.png', '/foo.png'], + ['https://example.com/foo.png', 'https://example.com/foo.png'] + ])('normalizes image src: %s → %s', async ([src, expected]) => { + const html = await md.renderAsync(`![logo](${src})`) + + expect(html).toContain(`src="${expected}"`) + }) }) - test.for([ - ['foo.png', './foo.png'], - ['./foo.png', './foo.png'], - ['../foo.png', '../foo.png'], - ['../../foo.png', '../../foo.png'], - ['/foo.png', '/foo.png'], - ['https://example.com/foo.png', 'https://example.com/foo.png'] - ])('normalizes image src: %s → %s', async ([src, expected]) => { - const html = await md.renderAsync(`![logo](${src})`) - expect(html).toContain(`src="${expected}"`) + describe('dimensions', () => { + test('adds width and height from local image dimensions', async () => { + const html = await md.renderAsync('![logo](./assets/vitepress.png)', env) + + expect(html).toContain('width="48"') + expect(html).toContain('height="48"') + }) + + test('adds width and height from public image dimensions', async () => { + const html = await md.renderAsync('![logo](/vitepress.png)', env) + + expect(html).toContain('width="48"') + expect(html).toContain('height="48"') + }) + + test('adds width and height when the image url is encoded', async () => { + const html = await md.renderAsync( + '![logo](./assets/vitepress%20logo.png)', + env + ) + + expect(html).toContain('src="./assets/vitepress logo.png"') + expect(html).toContain('width="48"') + expect(html).toContain('height="48"') + }) + + test('does not override explicit width and height', async () => { + const html = await md.renderAsync( + '![logo](/vitepress.png){width=100 height=200}', + env + ) + + expect(html).toContain('width="100"') + expect(html).toContain('height="200"') + }) + + test('scales height proportionally when only width is set', async () => { + const html = await md.renderAsync( + '![logo](/vitepress.png){width=96}', + env + ) + + // 48x48 image scaled to width=96 → height=96 + expect(html).toContain('width="96"') + expect(html).toContain('height="96"') + }) + + test('scales width proportionally when only height is set', async () => { + const html = await md.renderAsync( + '![logo](/vitepress.png){height=24}', + env + ) + + // 48x48 image scaled to height=24 → width=24 + expect(html).toContain('width="24"') + expect(html).toContain('height="24"') + }) + + test('ignores non-numeric width when scaling', async () => { + const html = await md.renderAsync( + '![logo](/vitepress.png){width=50%}', + env + ) + + expect(html).toContain('width="50%"') + expect(html).not.toContain('height=') + }) + + test('does not add dimensions for external images', async () => { + const html = await md.renderAsync( + '![logo](https://example.com/image.png)', + env + ) + + expect(html).not.toContain('width=') + expect(html).not.toContain('height=') + }) }) - test('adds loading="lazy" attribute when lazyLoading is enabled', async () => { - const mdLazy = new MarkdownItAsync() - imagePlugin(mdLazy as any, { lazyLoading: true }) - const html = await mdLazy.renderAsync('![logo](foo.png)') - expect(html).toContain('loading="lazy"') + describe('lazy loading', () => { + const mdLazy = createRenderer({ lazyLoading: true }) + + test('adds loading="lazy" when lazyLoading is enabled', async () => { + const html = await mdLazy.renderAsync('![logo](foo.png)') + + expect(html).toContain('loading="lazy"') + }) + + test('does not override user-specified loading strategy', async () => { + const html = await mdLazy.renderAsync('![logo](foo.png){loading=eager}') + + expect(html).toContain('loading="eager"') + expect(html).not.toContain('loading="lazy"') + }) }) }) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index 232496dfe..1d675f311 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -31,7 +31,7 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render(src, file, 'public') + const result = await render(src, file) expect(result.deadLinks).toContainEqual({ url: './missing', @@ -58,7 +58,7 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render(src, file, 'public') + const result = await render(src, file) expect(result.deadLinks).toContainEqual({ url: './missing', @@ -113,7 +113,7 @@ describe('node/markdownToVue', () => { siteConfig ) - const result = await render(src, file, 'public') + const result = await render(src, file) expect(result.vueSrc).toContain('

target text

') expect(result.vueSrc).toContain('

{ siteConfig ) - const result = await render( - '# Home\n', - 'C:/site/docs/en/index.md', - 'public' - ) + const result = await render('# Home\n', 'C:/site/docs/en/index.md') expect(result.pageData.relativePath).toBe('index.md') }) diff --git a/__tests__/unit/node/plugins/localSearchPlugin.test.ts b/__tests__/unit/node/plugins/localSearchPlugin.test.ts index c972795cd..cd2409f46 100644 --- a/__tests__/unit/node/plugins/localSearchPlugin.test.ts +++ b/__tests__/unit/node/plugins/localSearchPlugin.test.ts @@ -62,8 +62,14 @@ describe('node/plugins/localSearchPlugin', () => { const siteConfig = await resolveConfig(root, 'build', 'production') const plugin = await localSearchPlugin(siteConfig) - const indexModule = (await plugin.load?.call( - {} as never, + // vite calls configResolved before any other hook + await (plugin.configResolved as any)?.call( + {}, + { publicDir: siteConfig.publicDir } + ) + + const indexModule = (await (plugin.load as any)?.call( + {}, '/@localSearchIndex' )) as string @@ -73,10 +79,10 @@ describe('node/plugins/localSearchPlugin', () => { expect(indexModule).toContain('"zh": () => import(\'@localSearchIndexzh\')') const rootIndex = loadIndex( - (await plugin.load?.call({} as never, '/@localSearchIndexroot')) as string + (await (plugin.load as any)?.call({}, '/@localSearchIndexroot')) as string ) const zhIndex = loadIndex( - (await plugin.load?.call({} as never, '/@localSearchIndexzh')) as string + (await (plugin.load as any)?.call({}, '/@localSearchIndexzh')) as string ) expect(rootIndex.search('rootonlytoken')).toMatchObject([ diff --git a/docs/.vitepress/theme/styles.css b/docs/.vitepress/theme/styles.css index 2635e80dc..fbc4f012f 100644 --- a/docs/.vitepress/theme/styles.css +++ b/docs/.vitepress/theme/styles.css @@ -35,9 +35,3 @@ filter: drop-shadow(-2px 4px 6px rgba(0, 0, 0, 0.2)); padding: 18px; } - -/* used in reference/default-theme-search */ -img[src='/search.png'] { - width: 100%; - aspect-ratio: 1 / 1; -} diff --git a/package.json b/package.json index 99fd0c100..adb41cfbc 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "esbuild": "^0.27.7", "get-port": "^7.2.0", "gray-matter": "^4.0.3", + "image-size": "^2.0.2", "lint-staged": "^16.4.0", "lodash.template": "^4.18.1", "lru-cache": "^11.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6775cb743..3ccff2765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + image-size: + specifier: ^2.0.2 + version: 2.0.2 lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -1815,6 +1818,11 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -4323,6 +4331,8 @@ snapshots: ieee754@1.2.1: {} + image-size@2.0.2: {} + import-meta-resolve@4.2.0: {} is-core-module@2.16.2: diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index be280a3a1..4209fa5ae 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -203,8 +203,8 @@ export async function bundle( ) // also copy over public dir - const publicDir = path.resolve(config.srcDir, 'public') - if (fs.existsSync(publicDir)) { + const { publicDir } = config + if (publicDir && fs.existsSync(publicDir)) { // dereference symlinks like vite's own publicDir copy does, and so that // copying over an existing symlinked file does not fail with EEXIST await cp(publicDir, config.outDir, { recursive: true, dereference: true }) diff --git a/src/node/config.ts b/src/node/config.ts index f2deaf32c..3e7256e49 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -128,9 +128,18 @@ export async function resolveConfig( ? userThemeDir : DEFAULT_THEME_PATH + // mirrors vite's publicDir resolution + // re-synced with the resolved vite config in the plugin's configResolved hook + const vitePublicDir = userConfig.vite?.publicDir + const publicDir = + vitePublicDir === false || vitePublicDir === '' + ? '' + : normalizePath(path.resolve(srcDir, vitePublicDir || 'public')) + const config: Omit = { root, srcDir, + publicDir, assetsDir, site, themeDir, diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index 6a15ecd99..e162d8583 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -104,7 +104,8 @@ export function createContentLoader( config.srcDir, config.markdown, config.site.base, - config.logger + config.logger, + config.publicDir ) const raw = await pMap( diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 767675df1..356acbbc2 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -25,6 +25,7 @@ import { MarkdownItAsync, type MarkdownItAsyncOptions } from 'markdown-it-async' import attrsPlugin, { type MarkdownItAttrsOptions } from 'markdown-it-attrs' import mditCjkFriendly from 'markdown-it-cjk-friendly' import { full as emojiPlugin } from 'markdown-it-emoji' +import path from 'node:path' import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki' import type { Logger } from 'vite' import type { Awaitable } from '../shared' @@ -251,10 +252,13 @@ export async function createMarkdownRenderer( srcDir: string, options: MarkdownOptions = {}, base = '/', - logger: Pick = console + logger: Pick = console, + publicDir?: string ): Promise { if (md) return md + publicDir ??= path.resolve(srcDir, 'public') + const theme = options.theme ?? { light: 'github-light', dark: 'github-dark' } const codeCopyButtonTitle = options.codeCopyButtonTitle || 'Copy Code' @@ -283,7 +287,7 @@ export async function createMarkdownRenderer( }) snippetPlugin(md, srcDir) containerPlugin(md, options.container) - imagePlugin(md, options.image) + imagePlugin(md, publicDir, options.image) linkPlugin( md, { target: '_blank', rel: 'noreferrer', ...options.externalLinks }, diff --git a/src/node/markdown/plugins/image.ts b/src/node/markdown/plugins/image.ts index d8d015704..7d5f437ba 100644 --- a/src/node/markdown/plugins/image.ts +++ b/src/node/markdown/plugins/image.ts @@ -1,7 +1,12 @@ -// markdown-it plugin for normalizing image source +// markdown-it plugin for normalizing image source and auto adding +// width and height to images to avoid layout shift. import type { MarkdownItAsync } from 'markdown-it-async' -import { EXTERNAL_URL_RE } from '../../shared' +import type Token from 'markdown-it/lib/token.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { imageSize } from 'image-size' +import { EXTERNAL_URL_RE, type MarkdownEnv } from '../../shared' export interface Options { /** @@ -13,20 +18,87 @@ export interface Options { export const imagePlugin = ( md: MarkdownItAsync, + publicDir: string, { lazyLoading }: Options = {} ) => { const imageRule = md.renderer.rules.image! - md.renderer.rules.image = (tokens, idx, options, env, self) => { + md.renderer.rules.image = (tokens, idx, options, env: MarkdownEnv, self) => { const token = tokens[idx] + let url = token.attrGet('src') + if (url && !EXTERNAL_URL_RE.test(url)) { - // Normalize relative "foo.png" to "./foo.png" and decode for processing by bundlers - if (!/^\.*?\//.test(url)) url = './' + url - token.attrSet('src', decodeURIComponent(url)) + // Normalize relative "foo.png" to "./foo.png" and decode for + // processing by bundlers. + if (!/^\.*?\//.test(url)) { + url = './' + url + } + + url = decodeURIComponent(url) + token.attrSet('src', url) + + addImageDimensions(token, url, publicDir, env) } - if (lazyLoading) { + + if (lazyLoading && !token.attrGet('loading')) { token.attrSet('loading', 'lazy') } + return imageRule(tokens, idx, options, env, self) } } + +/** + * Adds width/height attributes based on the intrinsic image dimensions to + * avoid layout shift. If only one of the two is specified by the user, the + * other is scaled proportionally to it. + */ +function addImageDimensions( + token: Token, + url: string, + publicDir: string, + env: MarkdownEnv +) { + const width = token.attrGet('width') + const height = token.attrGet('height') + if (width && height) return + + const dimensions = getImageDimensions(url, publicDir, env) + if (!dimensions) return + + const aspectRatio = dimensions.width / dimensions.height + + if (!width) { + const newWidth = height ? +height * aspectRatio : dimensions.width + if (Number.isFinite(newWidth)) { + token.attrSet('width', Math.round(newWidth).toString()) + } + } + + if (!height) { + const newHeight = width ? +width / aspectRatio : dimensions.height + if (Number.isFinite(newHeight)) { + token.attrSet('height', Math.round(newHeight).toString()) + } + } +} + +function getImageDimensions(url: string, publicDir: string, env: MarkdownEnv) { + try { + const imagePath = resolveLocalImage(url, publicDir, env) + return imagePath ? imageSize(fs.readFileSync(imagePath)) : undefined + } catch { + // Best-effort: may fail if the env has no file path, the file doesn't + // exist, or `image-size` doesn't support the image format. + return + } +} + +function resolveLocalImage(src: string, publicDir: string, env: MarkdownEnv) { + if (src.startsWith('/')) { + // an empty publicDir means it's disabled + return publicDir ? path.join(publicDir, src) : undefined + } + const { realPath, path: _path } = env + return path.resolve(path.dirname(realPath ?? _path), src) +} diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 6f744acc6..98cf027ea 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -96,14 +96,11 @@ export async function createMarkdownToVueRenderFn( srcDir, options, base, - siteConfig?.logger + siteConfig?.logger, + siteConfig?.publicDir ) - return async ( - src: string, - file: string, - publicDir: string - ): Promise => { + return async (src: string, file: string): Promise => { const { pages, dynamicRoutes, rewrites, ts } = getResolutionCache(siteConfig) @@ -220,7 +217,10 @@ export async function createMarkdownToVueRenderFn( if ( !pages.includes(resolved) && - !fs.existsSync(path.resolve(dir, publicDir, `${resolved}.html`)) && + !( + siteConfig?.publicDir && + fs.existsSync(path.join(siteConfig.publicDir, `${resolved}.html`)) + ) && !shouldIgnoreDeadLink(url) ) { recordDeadLink(url, line) diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 94778dcf2..39ddd6655 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -112,6 +112,9 @@ export async function createVitePressPlugin( async configResolved(resolvedConfig) { config = resolvedConfig + // sync with the actual resolved publicDir (can be customized via + // vite config, or altered by other vite plugins) + siteConfig.publicDir = config.publicDir // pre-resolve git timestamps if (lastUpdated) await cacheAllGitTimestamps(srcDir) markdownToVue = await createMarkdownToVueRenderFn( @@ -209,8 +212,7 @@ export async function createVitePressPlugin( // transform .md files into vueSrc so plugin-vue can handle it const { vueSrc, deadLinks, includes, pageData } = await markdownToVue( code, - id, - config.publicDir + id ) allDeadLinks.push(...deadLinks) if (includes.length) { diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index b00552b87..b9cb1d01f 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -41,12 +41,8 @@ export async function localSearchPlugin( } } - const md = await createMarkdownRenderer( - siteConfig.srcDir, - siteConfig.markdown, - siteConfig.site.base, - siteConfig.logger - ) + // created in configResolved to use the resolved publicDir + let md: Awaited> const options = siteConfig.site.themeConfig.search.options || {} @@ -155,6 +151,16 @@ export async function localSearchPlugin( return { name: 'vitepress:local-search', + async configResolved(config) { + md = await createMarkdownRenderer( + siteConfig.srcDir, + siteConfig.markdown, + siteConfig.site.base, + siteConfig.logger, + config.publicDir + ) + }, + config: () => ({ optimizeDeps: { include: [ diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index e727087c7..abd0ab8d4 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -215,6 +215,7 @@ export interface SiteConfig extends Pick< > { root: string srcDir: string + publicDir: string site: SiteData configPath: string | undefined configDeps: string[]