feat(plugin-md-power): add support remote image in imageSize
This commit is contained in:
parent
2ac70ebdd1
commit
be47414c16
@ -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 = /<img(.*?)>/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<string, { width: number, height: number }>()
|
||||
const cache = new Map<string, ImgSize>()
|
||||
|
||||
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 `<img ${imgAttrs}>`
|
||||
})
|
||||
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<string, ImgSize>,
|
||||
) {
|
||||
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])
|
||||
}
|
||||
// <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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<ImgSize> {
|
||||
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 }))
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user