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-container": "catalog:prod",
"nanoid": "catalog:prod",
"p-map": "catalog:prod",
"qrcode": "catalog:prod",
"shiki": "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 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 = /<img(.*?)>/g
const REG_IMG_TAG = /<img([^>]*)>/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<string, ImgSize>()
/**
* 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 {}
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 ((width && height) || !url || !sizes[url] || src?.startsWith('data:'))
return imageRule(tokens, idx, options, env, self)
const size = resolveSize(sizes[url], width, height)
if (size) {
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 }
}
}
}
/**
* 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<void> {
async function scanImage(app: App): Promise<string[]> {
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<string>()
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)
}
// <img src=""> or <img srcset="xxx">
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
return Array.from(result)
}
/**
* Get original size of all images
*
*
*
*
* @param src - Image source /
* @param images - List of image URLs / URL
* @param includeRemote - Whether to include remote images /
* @returns Record of image URLs and their sizes / URL
*/
function addList(src: string) {
if (src && isLinkHttp(src)
&& !imgList.includes(src)
&& !BADGE_LIST.some(badge => src.startsWith(badge))) {
imgList.push(src)
}
}
async function getAllImageOriginalSize(
images: string[],
includeRemote = false,
): Promise<Record<string, ImgSize>> {
const result: Record<string, ImgSize> = {}
await Promise.all(imgList.map(async (src) => {
if (!cache.has(src)) {
const { width, height } = await fetchImageSize(src)
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)
cache.set(src, { 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
* @returns Image size /
*/
function fetchImageSize(src: string): Promise<ImgSize> {
function fetchRemoteImageSize(src: string): Promise<ImgSize> {
const link = new URL(src)
const promise = new Promise<ImgSize>((resolve) => {
@ -320,22 +303,11 @@ function fetchImageSize(src: string): Promise<ImgSize> {
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 })
const [, data] = attempt(imageSize, Buffer.concat(chunks))
if (data && data.width && data.height)
return resolve(data)
}
}
catch {}
}
try {
const { width, height } = imageSize(Buffer.concat(chunks))
resolve({ width: width!, height: height! })
}
catch {
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 })
}
}
/**
* 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 { createCodeTabIconGetter } from './container/codeTabs.js'
export { resolveImageSize } from './enhance/imageSize.js'
export { getImageOriginalSize, resolveImagePath } from './enhance/imageSize.js'
export * from './plugin.js'

3
pnpm-lock.yaml generated
View File

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

View File

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