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