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('![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[]