From be47414c16ba2bec6a9905734587d7a33516fe85 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Tue, 10 Sep 2024 16:46:58 +0800 Subject: [PATCH] feat(plugin-md-power): add support remote image in `imageSize` --- .../src/node/features/imageSize.ts | 229 +++++++++++++++--- plugins/plugin-md-power/src/node/plugin.ts | 2 +- plugins/plugin-md-power/src/shared/plugin.ts | 93 +++++++ 3 files changed, 285 insertions(+), 39 deletions(-) diff --git a/plugins/plugin-md-power/src/node/features/imageSize.ts b/plugins/plugin-md-power/src/node/features/imageSize.ts index b1aca66f..ebfdfb7f 100644 --- a/plugins/plugin-md-power/src/node/features/imageSize.ts +++ b/plugins/plugin-md-power/src/node/features/imageSize.ts @@ -1,14 +1,46 @@ +import { URL } from 'node:url' +import http from 'node:https' +import { Buffer } from 'node:buffer' import type { App } from 'vuepress' import type { Markdown, MarkdownEnv } from 'vuepress/markdown' import { isLinkExternal } from '@vuepress/helper' -import { fs, path } from '@vuepress/utils' +import { fs, path } from 'vuepress/utils' +import type { RenderRule } from 'markdown-it/lib/renderer.mjs' import imageSize from 'image-size' +import { resolveAttrs } from '../utils/resolveAttrs.js' -export function imageSizePlugin(app: App, md: Markdown): void { - if (!app.env.isBuild) +interface ImgSize { + width: number + height: number +} + +const REG_IMG = /!\[.*?\]\(.*?\)/g +const REG_IMG_TAG = //g +const REG_IMG_TAG_SRC = /src(?:set)?=(['"])(.+?)\1/g +const BADGE_LIST = [ + 'https://img.shields.io', + 'https://badge.fury.io', + 'https://badgen.net', + 'https://forthebadge.com', + 'https://vercel.com/button', +] + +export async function imageSizePlugin( + app: App, + md: Markdown, + type: boolean | 'local' | 'all' = false, +) { + if (!app.env.isBuild || !type) return - const cache = new Map() + const cache = new Map() + + if (type === 'all') { + try { + await scanRemoteImageSize(app, cache) + } + catch {} + } const imageRule = md.renderer.rules.image! md.renderer.rules.image = (tokens, idx, options, env: MarkdownEnv, self) => { @@ -17,25 +49,77 @@ export function imageSizePlugin(app: App, md: Markdown): void { const token = tokens[idx] const src = token.attrGet('src') - - if (!src || src.startsWith('data:') || isLinkExternal(src)) - return imageRule(tokens, idx, options, env, self) - const width = token.attrGet('width') const height = token.attrGet('height') + const size = resolveSize(src, width, height, env) + + 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) + + function createHtmlRule(rawHtmlRule: RenderRule): RenderRule { + return (tokens, idx, options, env, self) => { + const token = tokens[idx] + 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) + + if (!size) + return raw + + attrs.width = size.width + attrs.height = size.height + + const imgAttrs = Object.entries(attrs) + .map(([key, value]) => typeof value === 'boolean' ? key : `${key}="${value}"`) + .join(' ') + + return `` + }) + return rawHtmlRule(tokens, idx, options, env, self) + } + } + + 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 imageRule(tokens, idx, options, env, self) + return false - const filepath = resolveImageUrl(src, env) + const isExternal = isLinkExternal(src) + const filepath = isExternal ? src : resolveImageUrl(src, env, app) - if (!cache.has(filepath)) { - if (!filepath || !fs.existsSync(filepath)) - return imageRule(tokens, idx, options, env, self) + if (isExternal) { + if (!cache.has(filepath)) + return false + } + else { + if (!cache.has(filepath)) { + if (!fs.existsSync(filepath)) + return false - const { width: w, height: h } = imageSize(filepath) - if (!w || !h) - return imageRule(tokens, idx, options, env, self) - cache.set(filepath, { width: w, height: h }) + const { width: w, height: h } = imageSize(filepath) + if (!w || !h) + return false + + cache.set(filepath, { width: w, height: h }) + } } const { width: originalWidth, height: originalHeight } = cache.get(filepath)! @@ -44,32 +128,101 @@ export function imageSizePlugin(app: App, md: Markdown): void { if (width && !height) { const w = Number.parseInt(width, 10) - token.attrSet('width', `${w}`) - token.attrSet('height', `${Math.round(w / ratio)}`) + return { width: w, height: Math.round(w / ratio) } } else if (height && !width) { const h = Number.parseInt(height, 10) - token.attrSet('width', `${Math.round(h * ratio)}`) - token.attrSet('height', `${h}`) + return { width: Math.round(h * ratio), height: h } } else { - token.attrSet('width', `${originalWidth}`) - token.attrSet('height', `${originalHeight}`) + return { width: originalWidth, height: originalHeight } } - - return imageRule(tokens, idx, options, env, self) - } - - function resolveImageUrl(src: string, env: MarkdownEnv): string { - if (src[0] === '/') - return app.dir.public(src.slice(1)) - - if (env.filePathRelative) - return app.dir.source(path.join(path.dirname(env.filePathRelative), src)) - - if (env.filePath) - return path.resolve(env.filePath, src) - - return '' } } + +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 '' +} + +export async function scanRemoteImageSize( + app: App, + cache: Map, +) { + if (!app.env.isBuild) + return + const cwd = app.dir.source() + const files = await fs.readdir(cwd, { recursive: true }) + const imgList: string[] = [] + for (const file of files) { + const filepath = path.join(cwd, file) + if ( + (await (fs.stat(filepath))).isFile() + && !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]) + } + // or + const tagMatched = content.match(REG_IMG_TAG) ?? [] + for (const img of tagMatched) { + const src = img.match(REG_IMG_TAG_SRC)?.[2] ?? '' + addList(src) + } + } + } + + function addList(src: string) { + if (src && isLinkExternal(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)) { + const { width, height } = await fetchImageSize(src) + if (width && height) + cache.set(src, { width, height }) + } + })) +} + +function fetchImageSize(src: string): Promise { + const link = new URL(src) + + return new Promise((resolve) => { + http.get(link, async (stream) => { + 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 {} + } + const { width, height } = imageSize(Buffer.concat(chunks)) + resolve({ width: width!, height: height! }) + }).on('error', () => resolve({ width: 0, height: 0 })) + }) +} diff --git a/plugins/plugin-md-power/src/node/plugin.ts b/plugins/plugin-md-power/src/node/plugin.ts index 17ce150a..b3633c8c 100644 --- a/plugins/plugin-md-power/src/node/plugin.ts +++ b/plugins/plugin-md-power/src/node/plugin.ts @@ -39,7 +39,7 @@ export function markdownPowerPlugin(options: MarkdownPowerPluginOptions = {}): P }, extendsMarkdown: async (md: MarkdownIt, app) => { - imageSizePlugin(app, md) + await imageSizePlugin(app, md, options.imageSize) if (options.caniuse) { const caniuse = options.caniuse === true ? {} : options.caniuse diff --git a/plugins/plugin-md-power/src/shared/plugin.ts b/plugins/plugin-md-power/src/shared/plugin.ts index a9c5367d..b4ad7350 100644 --- a/plugins/plugin-md-power/src/shared/plugin.ts +++ b/plugins/plugin-md-power/src/shared/plugin.ts @@ -5,28 +5,121 @@ import type { PlotOptions } from './plot.js' import type { ReplOptions } from './repl.js' export interface MarkdownPowerPluginOptions { + /** + * 是否启用 PDF 嵌入语法 + * + * `@[pdf](pdf_url)` + * + * @default false + */ pdf?: boolean | PDFOptions // new syntax + /** + * 是否启用 iconify 图标嵌入语法 + * + * `:[collect:icon_name]:` + * + * @default false + */ icons?: boolean | IconsOptions + /** + * 是否启用 隐秘文本 语法 + * + * `!!plot_content!!` + * + * @default false + */ plot?: boolean | PlotOptions // video embed + /** + * 是否启用 bilibili 视频嵌入 + * + * `@[bilibili](bid)` + * + * @default false + */ bilibili?: boolean + /** + * 是否启用 youtube 视频嵌入 + * + * `@[youtube](video_id)` + * + * @default false + */ youtube?: boolean // code embed + /** + * 是否启用 codepen 嵌入 + * + * `@[codepen](pen_id)` + * + * @default false + */ codepen?: boolean /** * @deprecated */ replit?: boolean + /** + * 是否启用 codeSandbox 嵌入 + * + * `@[codesandbox](codesandbox_id)` + * + * @default false + */ codeSandbox?: boolean + /** + * 是否启用 jsfiddle 嵌入 + * + * `@[jsfiddle](jsfiddle_id)` + * + * @default false + */ jsfiddle?: boolean // container + /** + * 是否启用 REPL 容器语法 + * + * @default false + */ repl?: false | ReplOptions + /** + * 是否启用 文件树 容器语法 + * + * @default false + */ fileTree?: boolean + /** + * 是否启用 caniuse 嵌入语法 + * + * `@[caniuse](feature_name)` + * + * @default false + */ caniuse?: boolean | CanIUseOptions + + // enhance + /** + * 是否启用 自动填充 图片宽高属性 + * + * __请注意,无论是否启用,该功能仅在构建生产包时生效__ + * + * - 如果为 `true` ,等同于 `'local'` + * - 如果为 `local`,则仅对本地图片 添加 width 和 height + * - 如果为 `all`,则对所有图片(即包括 本地 和 远程) 添加 width 和 height + * + * 图片在加载过程中如果比较慢,从加载到完成的过程会导致页面布局不稳定,导致内容闪烁等。 + * 此功能通过给图片添加 `width` 和 `height` 属性来解决该问题。 + * + * 请谨慎使用 `all` 选项,该选项会在构建阶段发起网络请求,尝试加载远程图片以获取图片尺寸信息, + * 这可能会导致 构建时间变得更长(幸运的是获取尺寸信息只需要加载图片 几 KB 的数据包,因此耗时不会过长) + * + * @default false + */ + imageSize?: boolean | 'local' | 'all' }