diff --git a/__tests__/e2e/assets/vitepress logo.png b/__tests__/e2e/assets/vitepress logo.png
new file mode 100644
index 000000000..6cb0b3843
Binary files /dev/null and b/__tests__/e2e/assets/vitepress logo.png differ
diff --git a/__tests__/e2e/assets/vitepress.png b/__tests__/e2e/assets/vitepress.png
new file mode 100644
index 000000000..6cb0b3843
Binary files /dev/null and b/__tests__/e2e/assets/vitepress.png differ
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('')
- expect(html.trim()).toMatchInlineSnapshot(
- `"

"`
- )
+ // 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('')
+
+ expect(html.trim()).toMatchInlineSnapshot(
+ `"
"`
+ )
+ })
+
+ 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(``)
+
+ 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(``)
- expect(html).toContain(`src="${expected}"`)
+ describe('dimensions', () => {
+ test('adds width and height from local image dimensions', async () => {
+ const html = await md.renderAsync('', 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('', 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(
+ '',
+ 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(
+ '{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(
+ '{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(
+ '{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(
+ '{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(
+ '',
+ 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('')
- 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('')
+
+ expect(html).toContain('loading="lazy"')
+ })
+
+ test('does not override user-specified loading strategy', async () => {
+ const html = await mdLazy.renderAsync('{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[]