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>
pull/4932/merge
Bjorn Lu 2 days ago committed by GitHub
parent e21ffab582
commit 3868b64e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@ -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(
`"<p><img src="./foo.png" alt="logo"></p>"`
)
// 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(
`"<p><img src="./foo.png" alt="logo"></p>"`
)
})
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"')
})
})
})

@ -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('<p>target text</p>')
expect(result.vueSrc).toContain('<h3 id="child"')
@ -148,11 +148,7 @@ describe('node/markdownToVue', () => {
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')
})

@ -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([

@ -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;
}

@ -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",

@ -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:

@ -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 })

@ -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<SiteConfig, 'pages' | 'dynamicRoutes' | 'rewrites'> = {
root,
srcDir,
publicDir,
assetsDir,
site,
themeDir,

@ -104,7 +104,8 @@ export function createContentLoader<T = ContentData[]>(
config.srcDir,
config.markdown,
config.site.base,
config.logger
config.logger,
config.publicDir
)
const raw = await pMap(

@ -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<Logger, 'warn'> = console
logger: Pick<Logger, 'warn'> = console,
publicDir?: string
): Promise<MarkdownRenderer> {
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 },

@ -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)
}

@ -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<MarkdownCompileResult> => {
return async (src: string, file: string): Promise<MarkdownCompileResult> => {
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)

@ -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) {

@ -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<ReturnType<typeof createMarkdownRenderer>>
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: [

@ -215,6 +215,7 @@ export interface SiteConfig<ThemeConfig = any> extends Pick<
> {
root: string
srcDir: string
publicDir: string
site: SiteData<ThemeConfig>
configPath: string | undefined
configDeps: string[]

Loading…
Cancel
Save