refactor(plugin-md-power): optimize image size (#856)

This commit is contained in:
pengzhanbo 2026-02-15 11:33:40 +08:00 committed by GitHub
parent 1ed3dd9154
commit a4c9c85b00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 142 additions and 197 deletions

View File

@ -103,6 +103,7 @@
"markdown-it-cjk-friendly": "catalog:prod", "markdown-it-cjk-friendly": "catalog:prod",
"markdown-it-container": "catalog:prod", "markdown-it-container": "catalog:prod",
"nanoid": "catalog:prod", "nanoid": "catalog:prod",
"p-map": "catalog:prod",
"qrcode": "catalog:prod", "qrcode": "catalog:prod",
"shiki": "catalog:prod", "shiki": "catalog:prod",
"tm-grammars": "catalog:prod", "tm-grammars": "catalog:prod",

View File

@ -4,9 +4,10 @@ import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { Buffer } from 'node:buffer' import { Buffer } from 'node:buffer'
import http from 'node:https' import http from 'node:https'
import { URL } from 'node:url' import { URL } from 'node:url'
import { withTimeout } from '@pengzhanbo/utils' import { attempt, withTimeout } from '@pengzhanbo/utils'
import { isLinkExternal, isLinkHttp } from '@vuepress/helper' import { isLinkHttp } from '@vuepress/helper'
import imageSize from 'image-size' import imageSize from 'image-size'
import pMap from 'p-map'
import { fs, logger, path } from 'vuepress/utils' import { fs, logger, path } from 'vuepress/utils'
import { resolveAttrs } from '../utils/resolveAttrs.js' import { resolveAttrs } from '../utils/resolveAttrs.js'
@ -35,13 +36,13 @@ interface ImgSize {
* *
* markdown * markdown
*/ */
const REG_IMG = /!\[.*?\]\(.*?\)/g const REG_IMG = /!\[[^\]]*\]\([^)]*\)/g
/** /**
* Regular expression for matching HTML img tag * Regular expression for matching HTML img tag
* *
* HTML img * HTML img
*/ */
const REG_IMG_TAG = /<img(.*?)>/g const REG_IMG_TAG = /<img([^>]*)>/g
/** /**
* Regular expression for matching src/srcset attribute * Regular expression for matching src/srcset attribute
* *
@ -61,13 +62,6 @@ const BADGE_LIST = [
'https://vercel.com/button', 'https://vercel.com/button',
] ]
/**
* Cache for image sizes
*
*
*/
const cache = new Map<string, ImgSize>()
/** /**
* Image size plugin - Add width and height attributes to images * Image size plugin - Add width and height attributes to images
* *
@ -85,40 +79,35 @@ export async function imageSizePlugin(
if (!app.env.isBuild || !type) if (!app.env.isBuild || !type)
return return
if (type === 'all') { const start = performance.now()
const start = performance.now() const images = await scanImage(app)
try { const sizes = await getAllImageOriginalSize(images, type === 'all')
await scanRemoteImageSize(app)
} if (app.env.isDebug) {
catch {} logger.info(`[vuepress-plugin-md-power] imageSizePlugin: scan all images time spent: ${performance.now() - start}ms`)
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) => { 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 token = tokens[idx]
const src = token.attrGet('src')
const width = token.attrGet('width') const width = token.attrGet('width')
const height = token.attrGet('height') 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) { if ((width && height) || !url || !sizes[url] || src?.startsWith('data:'))
token.attrSet('width', `${size.width}`) return imageRule(tokens, idx, options, env, self)
token.attrSet('height', `${size.height}`)
} const size = resolveSize(sizes[url], width, height)
token.attrSet('width', `${size.width}`)
token.attrSet('height', `${size.height}`)
return imageRule(tokens, idx, options, env, self) return imageRule(tokens, idx, options, env, self)
} }
const rawHtmlBlockRule = md.renderer.rules.html_block! md.renderer.rules.html_block = createHtmlRule(md.renderer.rules.html_block!.bind(md))
const rawHtmlInlineRule = md.renderer.rules.html_inline! md.renderer.rules.html_inline = createHtmlRule(md.renderer.rules.html_inline!.bind(md))
md.renderer.rules.html_block = createHtmlRule(rawHtmlBlockRule)
md.renderer.rules.html_inline = createHtmlRule(rawHtmlInlineRule)
/** /**
* Create HTML rule for processing img tags * 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) => { token.content = token.content.replace(REG_IMG_TAG, (raw, info) => {
const { attrs } = resolveAttrs(info) const { attrs } = resolveAttrs(info)
const src = attrs.src || attrs.srcset 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 return raw
const size = resolveSize(sizes[url], width, height)
attrs.width = size.width attrs.width = size.width
attrs.height = size.height 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 width - Existing width /
* @param height - Existing height / * @param height - Existing height /
* @param env - Markdown environment / Markdown * @returns Image size /
* @returns Image size or false / false
*/ */
function resolveSize( function resolveSize(
src: string | null | undefined, original: ImgSize,
width: string | null | undefined, width: string | null,
height: string | null | undefined, height: string | null,
env: MarkdownEnv, ): ImgSize {
): false | ImgSize { const { width: originalWidth, height: originalHeight } = original
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)!
const ratio = originalWidth / originalHeight const ratio = originalWidth / originalHeight
@ -208,99 +167,123 @@ export async function imageSizePlugin(
const w = Number.parseInt(width, 10) const w = Number.parseInt(width, 10)
return { width: w, height: Math.round(w / ratio) } return { width: w, height: Math.round(w / ratio) }
} }
else if (height && !width) { if (height && !width) {
const h = Number.parseInt(height, 10) const h = Number.parseInt(height, 10)
return { width: Math.round(h * ratio), height: h } 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 * @param app - VuePress app / VuePress
* @returns List of image URLs / URL
*/ */
export async function scanRemoteImageSize(app: App): Promise<void> { async function scanImage(app: App): Promise<string[]> {
if (!app.env.isBuild) if (!app.env.isBuild)
return return []
const cwd = app.dir.source() const cwd = app.dir.source()
const files = await fs.readdir(cwd, { recursive: true }) const files = await fs.readdir(cwd, { recursive: true })
const imgList: string[] = [] const result = new Set<string>()
for (const file of files) {
await pMap(files as string[], async (file) => {
const filepath = path.join(cwd, file) const filepath = path.join(cwd, file)
if ( if (
(await (fs.stat(filepath))).isFile() (await (fs.stat(filepath))).isFile()
&& filepath.endsWith('.md')
&& !filepath.includes('.vuepress') && !filepath.includes('.vuepress')
&& !filepath.includes('node_modules') && !filepath.includes('node_modules')
&& filepath.endsWith('.md')
) { ) {
const content = await fs.readFile(filepath, 'utf-8') const content = await fs.readFile(filepath, 'utf-8')
// [xx](xxx) // [xx](xxx)
const syntaxMatched = content.match(REG_IMG) ?? [] const syntaxMatched = content.match(REG_IMG) ?? []
for (const img of syntaxMatched) { for (const img of syntaxMatched) {
const src = img.slice(img.indexOf('](') + 2, -1) const url = resolveImagePath(app, img.slice(img.indexOf('](') + 2, -1).split(/\s+/)[0], filepath)
addList(src.split(/\s+/)[0]) url && result.add(url)
} }
// <img src=""> or <img srcset="xxx"> // <img src=""> or <img srcset="xxx">
const tagMatched = content.match(REG_IMG_TAG) ?? [] const tagMatched = content.match(REG_IMG_TAG) ?? []
for (const img of tagMatched) { for (const img of tagMatched) {
const src = img.match(REG_IMG_TAG_SRC)?.[2] ?? '' const url = resolveImagePath(app, img.match(REG_IMG_TAG_SRC)?.[2] ?? '', filepath)
addList(src) url && result.add(url)
} }
} }
} }, { concurrency: 64 })
/** return Array.from(result)
* 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)
}
}
await Promise.all(imgList.map(async (src) => { /**
if (!cache.has(src)) { * Get original size of all images
const { width, height } = await fetchImageSize(src) *
if (width && height) *
cache.set(src, { width, height }) *
} * @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<Record<string, ImgSize>> {
const result: Record<string, ImgSize> = {}
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<ImgSize | null> {
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<void> {
* @param src - Image URL / URL * @param src - Image URL / URL
* @returns Image size / * @returns Image size /
*/ */
function fetchImageSize(src: string): Promise<ImgSize> { function fetchRemoteImageSize(src: string): Promise<ImgSize> {
const link = new URL(src) const link = new URL(src)
const promise = new Promise<ImgSize>((resolve) => { const promise = new Promise<ImgSize>((resolve) => {
@ -320,22 +303,11 @@ function fetchImageSize(src: string): Promise<ImgSize> {
const chunks: any[] = [] const chunks: any[] = []
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk) chunks.push(chunk)
try { const [, data] = attempt(imageSize, Buffer.concat(chunks))
const { width, height } = imageSize(Buffer.concat(chunks)) if (data && data.width && data.height)
if (width && height) { return resolve(data)
return resolve({ width, height })
}
}
catch {}
}
try {
const { width, height } = imageSize(Buffer.concat(chunks))
resolve({ width: width!, height: height! })
}
catch {
resolve({ width: 0, height: 0 })
} }
resolve({ width: 0, height: 0 })
}) })
.on('error', () => resolve({ width: 0, height: 0 })) .on('error', () => resolve({ width: 0, height: 0 }))
}) })
@ -347,35 +319,3 @@ function fetchImageSize(src: string): Promise<ImgSize> {
return Promise.resolve({ width: 0, height: 0 }) 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<ImgSize> {
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 }
}

View File

@ -1,4 +1,4 @@
export * from '../shared/index.js' export * from '../shared/index.js'
export { createCodeTabIconGetter } from './container/codeTabs.js' export { createCodeTabIconGetter } from './container/codeTabs.js'
export { resolveImageSize } from './enhance/imageSize.js' export { getImageOriginalSize, resolveImagePath } from './enhance/imageSize.js'
export * from './plugin.js' export * from './plugin.js'

3
pnpm-lock.yaml generated
View File

@ -719,6 +719,9 @@ importers:
nanoid: nanoid:
specifier: catalog:prod specifier: catalog:prod
version: 5.1.6 version: 5.1.6
p-map:
specifier: catalog:prod
version: 7.0.4
pyodide: pyodide:
specifier: catalog:peer specifier: catalog:peer
version: 0.29.3 version: 0.29.3

View File

@ -3,8 +3,9 @@ import type { App } from 'vuepress'
import type { ThemeBuiltinPlugins, ThemeData } from '../../shared/index.js' import type { ThemeBuiltinPlugins, ThemeData } from '../../shared/index.js'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import process from 'node:process' import process from 'node:process'
import { deleteKey } from '@pengzhanbo/utils'
import { watch } from 'chokidar' 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 { hash } from 'vuepress/utils'
import { resolveThemeData } from '../config/resolveThemeData.js' import { resolveThemeData } from '../config/resolveThemeData.js'
import { getThemeConfig } from '../loadConfig/index.js' import { getThemeConfig } from '../loadConfig/index.js'
@ -57,7 +58,7 @@ async function resolveBulletin(app: App, themeData: ThemeData) {
if (themeData.bulletin) { if (themeData.bulletin) {
if (bulletinFiles.root || themeData.bulletin.contentFile) { if (bulletinFiles.root || themeData.bulletin.contentFile) {
bulletinFiles.root = themeData.bulletin.contentFile || bulletinFiles.root bulletinFiles.root = themeData.bulletin.contentFile || bulletinFiles.root
delete themeData.bulletin.contentFile deleteKey(themeData.bulletin, 'contentFile')
themeData.bulletin!.content = await readBulletinFile(app, bulletinFiles.root) themeData.bulletin!.content = await readBulletinFile(app, bulletinFiles.root)
} }
else if (themeData.bulletin.content) { else if (themeData.bulletin.content) {
@ -84,7 +85,7 @@ async function resolveBulletin(app: App, themeData: ThemeData) {
if (bulletinFiles[locale] || themeData.locales[locale].bulletin.contentFile) { if (bulletinFiles[locale] || themeData.locales[locale].bulletin.contentFile) {
bulletinFiles[locale] = themeData.locales[locale].bulletin?.contentFile || bulletinFiles[locale] 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) themeData.locales[locale].bulletin.content = await readBulletinFile(app, bulletinFiles[locale], locale)
} }
else if (themeData.locales[locale].bulletin.content) { else if (themeData.locales[locale].bulletin.content) {
@ -142,24 +143,24 @@ async function processProfileImageSize(
const remote = imageSize === 'all' const remote = imageSize === 'all'
if (themeData.profile?.avatar) { if (themeData.profile?.avatar) {
const { width, height } = await resolveImageSize(app, themeData.profile.avatar, remote) const size = await getImageOriginalSize(resolveImagePath(app, themeData.profile.avatar), remote)
if (width && height) { if (size) {
themeData.profile = { themeData.profile = {
...themeData.profile, ...themeData.profile,
originalWidth: width, originalWidth: size.width,
originalHeight: height, originalHeight: size.height,
} as any } as any
} }
} }
if (themeData.locales) { if (themeData.locales) {
for (const locale of Object.keys(themeData.locales)) { for (const locale of Object.keys(themeData.locales)) {
if (themeData.locales[locale].profile?.avatar) { if (themeData.locales[locale].profile?.avatar) {
const { width, height } = await resolveImageSize(app, themeData.locales[locale].profile.avatar, remote) const size = await getImageOriginalSize(resolveImagePath(app, themeData.locales[locale].profile.avatar), remote)
if (width && height) { if (size) {
themeData.locales[locale].profile = { themeData.locales[locale].profile = {
...themeData.locales[locale].profile, ...themeData.locales[locale].profile,
originalWidth: width, originalWidth: size.width,
originalHeight: height, originalHeight: size.height,
} as any } as any
} }
} }