From a4c9c85b004f10aa1c9f94b53503ae88846b34d7 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Sun, 15 Feb 2026 11:33:40 +0800 Subject: [PATCH] refactor(plugin-md-power): optimize image size (#856) --- plugins/plugin-md-power/package.json | 1 + .../src/node/enhance/imageSize.ts | 310 +++++++----------- plugins/plugin-md-power/src/node/index.ts | 2 +- pnpm-lock.yaml | 3 + theme/src/node/prepare/prepareThemeData.ts | 23 +- 5 files changed, 142 insertions(+), 197 deletions(-) diff --git a/plugins/plugin-md-power/package.json b/plugins/plugin-md-power/package.json index 96a32ebd..5d185b61 100644 --- a/plugins/plugin-md-power/package.json +++ b/plugins/plugin-md-power/package.json @@ -103,6 +103,7 @@ "markdown-it-cjk-friendly": "catalog:prod", "markdown-it-container": "catalog:prod", "nanoid": "catalog:prod", + "p-map": "catalog:prod", "qrcode": "catalog:prod", "shiki": "catalog:prod", "tm-grammars": "catalog:prod", diff --git a/plugins/plugin-md-power/src/node/enhance/imageSize.ts b/plugins/plugin-md-power/src/node/enhance/imageSize.ts index 852d20d2..dd6624b9 100644 --- a/plugins/plugin-md-power/src/node/enhance/imageSize.ts +++ b/plugins/plugin-md-power/src/node/enhance/imageSize.ts @@ -4,9 +4,10 @@ import type { Markdown, MarkdownEnv } from 'vuepress/markdown' import { Buffer } from 'node:buffer' import http from 'node:https' import { URL } from 'node:url' -import { withTimeout } from '@pengzhanbo/utils' -import { isLinkExternal, isLinkHttp } from '@vuepress/helper' +import { attempt, withTimeout } from '@pengzhanbo/utils' +import { isLinkHttp } from '@vuepress/helper' import imageSize from 'image-size' +import pMap from 'p-map' import { fs, logger, path } from 'vuepress/utils' import { resolveAttrs } from '../utils/resolveAttrs.js' @@ -35,13 +36,13 @@ interface ImgSize { * * 匹配 markdown 图片语法的正则表达式 */ -const REG_IMG = /!\[.*?\]\(.*?\)/g +const REG_IMG = /!\[[^\]]*\]\([^)]*\)/g /** * Regular expression for matching HTML img tag * * 匹配 HTML img 标签的正则表达式 */ -const REG_IMG_TAG = //g +const REG_IMG_TAG = /]*)>/g /** * Regular expression for matching src/srcset attribute * @@ -61,13 +62,6 @@ const BADGE_LIST = [ 'https://vercel.com/button', ] -/** - * Cache for image sizes - * - * 图片尺寸缓存 - */ -const cache = new Map() - /** * Image size plugin - Add width and height attributes to images * @@ -85,40 +79,35 @@ export async function imageSizePlugin( if (!app.env.isBuild || !type) return - if (type === 'all') { - const start = performance.now() - try { - await scanRemoteImageSize(app) - } - catch {} - if (app.env.isDebug) { - logger.info(`[vuepress-plugin-md-power] imageSizePlugin: scan all images time spent: ${performance.now() - start}ms`) - } + const start = performance.now() + const images = await scanImage(app) + const sizes = await getAllImageOriginalSize(images, type === 'all') + + if (app.env.isDebug) { + logger.info(`[vuepress-plugin-md-power] imageSizePlugin: scan all images time spent: ${performance.now() - start}ms`) } - const imageRule = md.renderer.rules.image! + const imageRule = md.renderer.rules.image!.bind(md) md.renderer.rules.image = (tokens, idx, options, env: MarkdownEnv, self) => { - if (!env.filePathRelative || !env.filePath) - return imageRule(tokens, idx, options, env, self) - const token = tokens[idx] - const src = token.attrGet('src') const width = token.attrGet('width') const height = token.attrGet('height') - const size = resolveSize(src, width, height, env) + const src = token.attrGet('src') + const url = resolveImagePath(app, src, env.filePath) - if (size) { - token.attrSet('width', `${size.width}`) - token.attrSet('height', `${size.height}`) - } + if ((width && height) || !url || !sizes[url] || src?.startsWith('data:')) + return imageRule(tokens, idx, options, env, self) + + const size = resolveSize(sizes[url], width, height) + + token.attrSet('width', `${size.width}`) + token.attrSet('height', `${size.height}`) return imageRule(tokens, idx, options, env, self) } - const rawHtmlBlockRule = md.renderer.rules.html_block! - const rawHtmlInlineRule = md.renderer.rules.html_inline! - md.renderer.rules.html_block = createHtmlRule(rawHtmlBlockRule) - md.renderer.rules.html_inline = createHtmlRule(rawHtmlInlineRule) + md.renderer.rules.html_block = createHtmlRule(md.renderer.rules.html_block!.bind(md)) + md.renderer.rules.html_inline = createHtmlRule(md.renderer.rules.html_inline!.bind(md)) /** * Create HTML rule for processing img tags @@ -134,11 +123,14 @@ export async function imageSizePlugin( token.content = token.content.replace(REG_IMG_TAG, (raw, info) => { const { attrs } = resolveAttrs(info) const src = attrs.src || attrs.srcset - const size = resolveSize(src, attrs.width, attrs.height, env) + const url = resolveImagePath(app, src, env.filepath) + const { width, height } = attrs - if (!size) + if ((width && height) || !url || !sizes[url] || src?.startsWith('data:')) return raw + const size = resolveSize(sizes[url], width, height) + attrs.width = size.width attrs.height = size.height @@ -157,50 +149,17 @@ export async function imageSizePlugin( * * 从源解析图片尺寸 * - * @param src - Image source / 图片源 + * @param original - Image source / 图片源 * @param width - Existing width / 现有宽度 * @param height - Existing height / 现有高度 - * @param env - Markdown environment / Markdown 环境 - * @returns Image size or false / 图片尺寸或 false + * @returns Image size / 图片尺寸 */ function resolveSize( - src: string | null | undefined, - width: string | null | undefined, - height: string | null | undefined, - env: MarkdownEnv, - ): false | ImgSize { - if (!src || src.startsWith('data:')) - return false - - if (width && height) - return false - - const isExternal = isLinkExternal(src, env.base) - const filepath = isExternal ? src : resolveImageUrl(src, env, app) - - if (isExternal) { - if (!cache.has(filepath)) - return false - } - else { - if (!cache.has(filepath)) { - if (!fs.existsSync(filepath)) - return false - - try { - const { width: w, height: h } = imageSize(fs.readFileSync(filepath)) - if (!w || !h) - return false - - cache.set(filepath, { width: w, height: h }) - } - catch { - return false - } - } - } - - const { width: originalWidth, height: originalHeight } = cache.get(filepath)! + original: ImgSize, + width: string | null, + height: string | null, + ): ImgSize { + const { width: originalWidth, height: originalHeight } = original const ratio = originalWidth / originalHeight @@ -208,99 +167,123 @@ export async function imageSizePlugin( const w = Number.parseInt(width, 10) return { width: w, height: Math.round(w / ratio) } } - else if (height && !width) { + if (height && !width) { const h = Number.parseInt(height, 10) return { width: Math.round(h * ratio), height: h } } - else { - return { width: originalWidth, height: originalHeight } - } + return { width: originalWidth, height: originalHeight } } } /** - * Resolve image URL from source + * Scan all images in the source directory * - * 从源解析图片 URL - * - * @param src - Image source / 图片源 - * @param env - Markdown environment / Markdown 环境 - * @param app - VuePress app / VuePress 应用 - * @returns Resolved image URL / 解析后的图片 URL - */ -function resolveImageUrl(src: string, env: MarkdownEnv, app: App): string { - if (src[0] === '/') - return app.dir.public(src.slice(1)) - - if (env.filePathRelative && src[0] === '.') - return app.dir.source(path.join(path.dirname(env.filePathRelative), src)) - - // fallback - if (env.filePath && (src[0] === '.' || src[0] === '/')) - return path.resolve(env.filePath, src) - - return path.resolve(src) -} - -/** - * Scan remote image sizes in markdown files - * - * 扫描 markdown 文件中的远程图片尺寸 + * 扫描源目录中的所有图片 * * @param app - VuePress app / VuePress 应用 + * @returns List of image URLs / 图片 URL 列表 */ -export async function scanRemoteImageSize(app: App): Promise { +async function scanImage(app: App): Promise { if (!app.env.isBuild) - return + return [] + const cwd = app.dir.source() const files = await fs.readdir(cwd, { recursive: true }) - const imgList: string[] = [] - for (const file of files) { + const result = new Set() + + await pMap(files as string[], async (file) => { const filepath = path.join(cwd, file) if ( (await (fs.stat(filepath))).isFile() + && filepath.endsWith('.md') && !filepath.includes('.vuepress') && !filepath.includes('node_modules') - && filepath.endsWith('.md') ) { const content = await fs.readFile(filepath, 'utf-8') // [xx](xxx) const syntaxMatched = content.match(REG_IMG) ?? [] for (const img of syntaxMatched) { - const src = img.slice(img.indexOf('](') + 2, -1) - addList(src.split(/\s+/)[0]) + const url = resolveImagePath(app, img.slice(img.indexOf('](') + 2, -1).split(/\s+/)[0], filepath) + url && result.add(url) } // or const tagMatched = content.match(REG_IMG_TAG) ?? [] for (const img of tagMatched) { - const src = img.match(REG_IMG_TAG_SRC)?.[2] ?? '' - addList(src) + const url = resolveImagePath(app, img.match(REG_IMG_TAG_SRC)?.[2] ?? '', filepath) + url && result.add(url) } } - } + }, { concurrency: 64 }) - /** - * Add source to image list - * - * 将源添加到图片列表 - * - * @param src - Image source / 图片源 - */ - function addList(src: string) { - if (src && isLinkHttp(src) - && !imgList.includes(src) - && !BADGE_LIST.some(badge => src.startsWith(badge))) { - imgList.push(src) - } - } + return Array.from(result) +} - await Promise.all(imgList.map(async (src) => { - if (!cache.has(src)) { - const { width, height } = await fetchImageSize(src) - if (width && height) - cache.set(src, { width, height }) - } - })) +/** + * Get original size of all images + * + * 获取所有图片的原始尺寸 + * + * @param images - List of image URLs / 图片 URL 列表 + * @param includeRemote - Whether to include remote images / 是否包含远程图片 + * @returns Record of image URLs and their sizes / 图片 URL 及其尺寸的记录 + */ +async function getAllImageOriginalSize( + images: string[], + includeRemote = false, +): Promise> { + const result: Record = {} + + await pMap(images, async (src) => { + const size = await getImageOriginalSize(src, includeRemote) + if (size) + result[src] = size + }, { concurrency: 64 }) + + return result +} + +export async function getImageOriginalSize( + image: string | null | undefined, + includeRemote = false, +): Promise { + if (!image) + return null + + const isRemote = isLinkHttp(image) + // remote image + if (isRemote && includeRemote && !BADGE_LIST.some(badge => image.startsWith(badge))) { + const { width, height } = await fetchRemoteImageSize(image.startsWith('//') ? `https:${image}` : image) + if (width && height) + return { width, height } + } + if (!isRemote) { + const [, data] = attempt(() => imageSize(fs.readFileSync(image))) + if (data?.width && data?.height) + return { width: data.width, height: data.height } + } + return null +} + +/** + * Resolve image path from source + * + * 从源解析图片路径 + * + * @param app - VuePress app / VuePress 应用 + * @param src - Image source / 图片源 + * @param currentPath - Current path / 当前路径 + * @returns Image path / 图片路径 + */ +export function resolveImagePath(app: App, src?: string | null, currentPath?: string | null): string { + if (!src) + return '' + if (isLinkHttp(src)) + return src + + if (src[0] === '/') + return app.dir.public(src.slice(1)) + + return currentPath ? path.resolve(currentPath, src) : '' } /** @@ -311,7 +294,7 @@ export async function scanRemoteImageSize(app: App): Promise { * @param src - Image URL / 图片 URL * @returns Image size / 图片尺寸 */ -function fetchImageSize(src: string): Promise { +function fetchRemoteImageSize(src: string): Promise { const link = new URL(src) const promise = new Promise((resolve) => { @@ -320,22 +303,11 @@ function fetchImageSize(src: string): Promise { const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) - try { - const { width, height } = imageSize(Buffer.concat(chunks)) - if (width && height) { - return resolve({ width, height }) - } - } - catch {} - } - - try { - const { width, height } = imageSize(Buffer.concat(chunks)) - resolve({ width: width!, height: height! }) - } - catch { - resolve({ width: 0, height: 0 }) + const [, data] = attempt(imageSize, Buffer.concat(chunks)) + if (data && data.width && data.height) + return resolve(data) } + resolve({ width: 0, height: 0 }) }) .on('error', () => resolve({ width: 0, height: 0 })) }) @@ -347,35 +319,3 @@ function fetchImageSize(src: string): Promise { return Promise.resolve({ width: 0, height: 0 }) } } - -/** - * Resolve image size from URL - * - * 从 URL 解析图片尺寸 - * - * @param app - VuePress app / VuePress 应用 - * @param url - Image URL / 图片 URL - * @param remote - Whether to fetch remote images / 是否获取远程图片 - * @returns Image size / 图片尺寸 - */ -export async function resolveImageSize(app: App, url: string, remote = false): Promise { - if (cache.has(url)) - return cache.get(url)! - - if (isLinkHttp(url) && remote) { - return await fetchImageSize(url) - } - - if (url[0] === '/') { - const filepath = app.dir.public(url.slice(1)) - if (fs.existsSync(filepath)) { - try { - const { width, height } = imageSize(fs.readFileSync(filepath)) - return { width: width!, height: height! } - } - catch {} - } - } - - return { width: 0, height: 0 } -} diff --git a/plugins/plugin-md-power/src/node/index.ts b/plugins/plugin-md-power/src/node/index.ts index f3b2fb9f..4c3f1ffa 100644 --- a/plugins/plugin-md-power/src/node/index.ts +++ b/plugins/plugin-md-power/src/node/index.ts @@ -1,4 +1,4 @@ export * from '../shared/index.js' export { createCodeTabIconGetter } from './container/codeTabs.js' -export { resolveImageSize } from './enhance/imageSize.js' +export { getImageOriginalSize, resolveImagePath } from './enhance/imageSize.js' export * from './plugin.js' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea8c4c2d..dfa3d308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,6 +719,9 @@ importers: nanoid: specifier: catalog:prod version: 5.1.6 + p-map: + specifier: catalog:prod + version: 7.0.4 pyodide: specifier: catalog:peer version: 0.29.3 diff --git a/theme/src/node/prepare/prepareThemeData.ts b/theme/src/node/prepare/prepareThemeData.ts index 991d2aa7..53adbb39 100644 --- a/theme/src/node/prepare/prepareThemeData.ts +++ b/theme/src/node/prepare/prepareThemeData.ts @@ -3,8 +3,9 @@ import type { App } from 'vuepress' import type { ThemeBuiltinPlugins, ThemeData } from '../../shared/index.js' import fs from 'node:fs/promises' import process from 'node:process' +import { deleteKey } from '@pengzhanbo/utils' import { watch } from 'chokidar' -import { resolveImageSize } from 'vuepress-plugin-md-power' +import { getImageOriginalSize, resolveImagePath } from 'vuepress-plugin-md-power' import { hash } from 'vuepress/utils' import { resolveThemeData } from '../config/resolveThemeData.js' import { getThemeConfig } from '../loadConfig/index.js' @@ -57,7 +58,7 @@ async function resolveBulletin(app: App, themeData: ThemeData) { if (themeData.bulletin) { if (bulletinFiles.root || themeData.bulletin.contentFile) { bulletinFiles.root = themeData.bulletin.contentFile || bulletinFiles.root - delete themeData.bulletin.contentFile + deleteKey(themeData.bulletin, 'contentFile') themeData.bulletin!.content = await readBulletinFile(app, bulletinFiles.root) } else if (themeData.bulletin.content) { @@ -84,7 +85,7 @@ async function resolveBulletin(app: App, themeData: ThemeData) { if (bulletinFiles[locale] || themeData.locales[locale].bulletin.contentFile) { bulletinFiles[locale] = themeData.locales[locale].bulletin?.contentFile || bulletinFiles[locale] - delete themeData.locales[locale].bulletin.contentFile + deleteKey(themeData.locales[locale].bulletin, 'contentFile') themeData.locales[locale].bulletin.content = await readBulletinFile(app, bulletinFiles[locale], locale) } else if (themeData.locales[locale].bulletin.content) { @@ -142,24 +143,24 @@ async function processProfileImageSize( const remote = imageSize === 'all' if (themeData.profile?.avatar) { - const { width, height } = await resolveImageSize(app, themeData.profile.avatar, remote) - if (width && height) { + const size = await getImageOriginalSize(resolveImagePath(app, themeData.profile.avatar), remote) + if (size) { themeData.profile = { ...themeData.profile, - originalWidth: width, - originalHeight: height, + originalWidth: size.width, + originalHeight: size.height, } as any } } if (themeData.locales) { for (const locale of Object.keys(themeData.locales)) { if (themeData.locales[locale].profile?.avatar) { - const { width, height } = await resolveImageSize(app, themeData.locales[locale].profile.avatar, remote) - if (width && height) { + const size = await getImageOriginalSize(resolveImagePath(app, themeData.locales[locale].profile.avatar), remote) + if (size) { themeData.locales[locale].profile = { ...themeData.locales[locale].profile, - originalWidth: width, - originalHeight: height, + originalWidth: size.width, + originalHeight: size.height, } as any } }