diff --git a/.vscode/settings.json b/.vscode/settings.json index 2892e63..903b875 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "cSpell.words": [ - "middlewares" - ] + "cSpell.words": ["middlewares"] } diff --git a/README.md b/README.md index b058c17..4f7cc0e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ export default defineConfig(() => ({ ``` +### 匹配规则 +```ts +const rules = [ + '', + '/:width?/:height?{.:type}?', + '/bg/:background/:width?/:height?{.:type}?', + '/text/:text/:width?/:height?{.:type}?', + '/text/:text/bg/:background/:width?/:height?{.:type}?', + '/bg/:background/text/:text/:width?/:height?{.:type}?', + ].map((rule) => `${prefix}${rule}`) +``` +- width +- height +- background +- text +- type + # 示例 diff --git a/package.json b/package.json index 551882e..be4eee6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "release:changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", "release": "bumpp package.json --execute=\"pnpm release:changelog\" --commit --all --push --tag && pnpm publish --access public" }, + "prettier": "@pengzhanbo/prettier-config", "dependencies": { "debug": "^4.3.4", "lru-cache": "^7.14.1", @@ -59,7 +60,6 @@ "peerDependencies": { "vite": ">=3.0.0" }, - "prettier": "@pengzhanbo/prettier-config", "packageManager": "pnpm@7.18.2", "engines": { "node": "^14.18.0 || >=16" diff --git a/src/generateImage.ts b/src/generateImage.ts deleted file mode 100644 index 3a33a43..0000000 --- a/src/generateImage.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Create, CreateText } from 'sharp' -import sharp from 'sharp' -import type { ImageType } from './types' - -export type CreateOptions = Create - -export type TextOptions = CreateText - -export async function generateImage( - type: ImageType = 'png', - createOptions: CreateOptions, - textOptions?: TextOptions, -) { - let image = sharp({ create: createOptions }) - - textOptions && image.composite([{ input: { text: textOptions } }]) - - switch (type) { - case 'jpg': - case 'jpeg': - image = image.jpeg({ quality: 100 }) - break - case 'webp': - image = image.webp({ quality: 100 }) - break - case 'heif': - image = image.heif({ quality: 100 }) - break - case 'avif': - image = image.avif({ quality: 100 }) - break - case 'gif': - image = image.gif() - break - default: - image = image.png({ compressionLevel: 3 }) - } - - const buf = await image.toBuffer() - - return buf -} diff --git a/src/middlewares.ts b/src/middlewares.ts deleted file mode 100644 index 0456b30..0000000 --- a/src/middlewares.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { parse as queryParse } from 'node:querystring' -import { parse as urlParse } from 'node:url' -import { match, pathToRegexp } from 'path-to-regexp' -import type { Connect } from 'vite' -import { cache } from './cache' -import { DEFAULT_PARAMS } from './constants' -import type { CreateOptions, TextOptions } from './generateImage' -import { generateImage } from './generateImage' -import { generatePathRules } from './pathRules' -import type { - ImagePlaceholderOptions, - ImagePlaceholderParams, - ImagePlaceholderQuery, -} from './types' -import { formatColor, formatText, getMimeType, logger } from './utils' - -export function imagePlaceholderMiddlewares( - options: Required, -): Connect.NextHandleFunction { - const pathRules = generatePathRules(options.prefix) - - return async function (req, res, next) { - const url = req.url! - - if (!url.startsWith(options.prefix)) return next() - - if (cache.has(url)) { - const img = cache.get(url)! - res.setHeader('Accept-Ranges', 'bytes') - res.setHeader('Content-Type', getMimeType(img.type)) - res.end(img.content) - return - } - - const { query: UrlQuery, pathname } = urlParse(url) - - const rule = pathRules.find((rule) => { - return pathToRegexp(rule).test(pathname!) - }) - if (!rule) return next() - - logger(pathname) - - const urlMatch = match(rule, { decode: decodeURIComponent })(pathname!) || { - params: { - width: options.width, - background: options.background, - type: options.type, - } as ImagePlaceholderParams, - } - - const params = urlMatch.params as ImagePlaceholderParams - const query = queryParse(UrlQuery || '') as ImagePlaceholderQuery - const imgType = params.type || DEFAULT_PARAMS.type! - const width = Number(params.width) || 300 - const height = Number(params.height) || Math.ceil((width / 16) * 9) - - const createOptions: CreateOptions = { - width, - height, - channels: 4, - background: formatColor(params.background || options.background), - } - if (Number(query.noise) === 1) { - createOptions.noise = { - type: 'gaussian', - mean: - query.noiseMean || Math.ceil((Math.min(width, height) / 100) * 10), - sigma: query.noiseSigma || 10, - } - } - - const textOptions: TextOptions = { - dpi: Math.floor((Math.min(width, height) / 4) * 3) || 1, - text: formatText( - params.text || options.text || `${width}X${height}`, - formatColor(query.textColor, true) || options.textColor, - ), - rgba: true, - align: query.textAlign, - justify: query.textJustify === 1, - spacing: query.textSpacing, - } - - const imgBuf = await generateImage(imgType, createOptions, textOptions) - cache.set(url, { - type: imgType, - content: imgBuf, - }) - res.setHeader('Accept-Ranges', 'bytes') - res.setHeader('Content-type', getMimeType(imgType)) - res.end(imgBuf) - } -} diff --git a/src/pathToImage.ts b/src/pathToImage.ts new file mode 100644 index 0000000..96e5100 --- /dev/null +++ b/src/pathToImage.ts @@ -0,0 +1,117 @@ +import { parse as urlParse } from 'node:url' +import { match, pathToRegexp } from 'path-to-regexp' +import type { Create, CreateText } from 'sharp' +import sharp from 'sharp' +import { cache } from './cache' +import { DEFAULT_PARAMS } from './constants' +import type { + ImageCacheItem, + ImagePlaceholderOptions, + ImagePlaceholderParams, + ImagePlaceholderQuery, + ImageType, +} from './types' +import { formatColor, formatText, getBackground } from './utils' + +export type CreateOptions = Create + +export type TextOptions = CreateText + +export async function pathToImage( + url: string, + rules: string[], + options: Required, +): Promise { + if (cache.has(url)) { + return cache.get(url) + } + const { query: urlQuery, pathname } = urlParse(url, true) + + const rule = rules.find((rule) => { + return pathToRegexp(rule).test(pathname!) + }) + + if (!rule) return + + const urlMatch = match(rule, { decode: decodeURIComponent })(pathname!) || { + params: { + width: options.width, + background: getBackground(options.background), + type: options.type, + } as ImagePlaceholderParams, + } + + const params = urlMatch.params as ImagePlaceholderParams + const query = urlQuery as ImagePlaceholderQuery + const imgType = params.type || DEFAULT_PARAMS.type! + const width = Number(params.width) || 300 + const height = Number(params.height) || Math.ceil((width / 16) * 9) + + const createOptions: CreateOptions = { + width, + height, + channels: 4, + background: formatColor( + params.background || getBackground(options.background), + ), + } + if (Number(query.noise) === 1) { + createOptions.noise = { + type: 'gaussian', + mean: query.noiseMean || Math.ceil((Math.min(width, height) / 100) * 10), + sigma: query.noiseSigma || 10, + } + } + + const textOptions: TextOptions = { + dpi: Math.floor((Math.min(width, height) / 4) * 3) || 1, + text: formatText( + params.text || options.text || `${width}x${height}`, + formatColor(query.textColor, true) || options.textColor, + ), + rgba: true, + } + + const imgBuf = await createImage(imgType, createOptions, textOptions) + const result: ImageCacheItem = { + type: imgType, + buffer: imgBuf, + } + cache.set(url, result) + return result +} + +export async function createImage( + type: ImageType = 'png', + createOptions: CreateOptions, + textOptions?: TextOptions, +) { + let image = sharp({ create: createOptions }) + + textOptions && image.composite([{ input: { text: textOptions } }]) + + switch (type) { + case 'jpg': + case 'jpeg': + image = image.jpeg({ quality: 100 }) + break + case 'webp': + image = image.webp({ quality: 100 }) + break + case 'heif': + image = image.heif({ quality: 100 }) + break + case 'avif': + image = image.avif({ quality: 100 }) + break + case 'gif': + image = image.gif() + break + default: + image = image.png({ compressionLevel: 1 }) + } + + const buf = await image.toBuffer() + + return buf +} diff --git a/src/plugin.ts b/src/plugin.ts index 0e2d0b0..89b9f75 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,16 +1,19 @@ import type { Plugin } from 'vite' import { DEFAULT_PREFIX } from './constants' -import { imagePlaceholderMiddlewares } from './middlewares' +import { generatePathRules } from './pathRules' +import { pathToImage } from './pathToImage' import type { ImagePlaceholderOptions } from './types' +import { getMimeType, logger } from './utils' const parseOptions = ( options: ImagePlaceholderOptions, + server = false, ): Required => { options = Object.assign( { prefix: DEFAULT_PREFIX, background: '#ccc', - textColor: '#999', + textColor: '#333', width: 300, type: 'png', } as ImagePlaceholderOptions, @@ -18,18 +21,69 @@ const parseOptions = ( ) options.prefix = `/${options.prefix}`.replace(/\/\//g, '/').replace(/\/$/, '') + if (!server) { + options.prefix = options.prefix.slice(1) + } + return options as Required } -export function imagePlaceholderPlugin( +function placeholderServerPlugin( options: ImagePlaceholderOptions = {}, ): Plugin { - const opts = parseOptions(options) + const opts = parseOptions(options, true) + const pathRules = generatePathRules(opts.prefix) + return { - name: 'vite-plugin-image-placeholder', + name: 'vite-plugin-image-placeholder-server', apply: 'serve', async configureServer({ middlewares }) { - middlewares.use(imagePlaceholderMiddlewares(opts)) + middlewares.use(async function (req, res, next) { + const url = req.url! + if (!url.startsWith(opts.prefix)) return next() + logger(url) + + try { + const image = await pathToImage(url, pathRules, opts) + + if (!image) return next() + + res.setHeader('Accept-Ranges', 'bytes') + res.setHeader('Content-Type', getMimeType(image.type)) + res.end(image.buffer) + return + } catch (e) { + console.error(e) + } + next() + }) }, } } + +function placeholderInjectPlugin( + options: ImagePlaceholderOptions = {}, +): Plugin { + const opts = parseOptions(options) + const moduleId = `virtual:${opts.prefix}` + const resolveVirtualModuleId = `\0${moduleId}` + return { + name: 'vite-plugin-image-placeholder-inject', + resolveId(id) { + if (id.startsWith(moduleId)) { + return `\0${id}` + } + }, + async load(id) { + if (id.startsWith(resolveVirtualModuleId)) { + // const url = id.replace(resolveVirtualModuleId, '') + } + }, + } +} + +export function imagePlaceholderPlugin( + options: ImagePlaceholderOptions = {}, +): Plugin[] { + return [placeholderServerPlugin(options), placeholderInjectPlugin(options)] +} diff --git a/src/types.ts b/src/types.ts index bf77fc8..73f2b2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,12 @@ export interface ImagePlaceholderOptions { prefix?: string - background?: string + background?: string | string[] textColor?: string text?: string type?: ImageType width?: number height?: number + ratio?: string } export type ImageType = @@ -30,12 +31,9 @@ export interface ImagePlaceholderQuery { noiseMean?: number noiseSigma?: number textColor?: string - textAlign?: 'left' | 'center' | 'right' | 'centre' - textJustify?: 0 | 1 - textSpacing?: number } export interface ImageCacheItem { type: ImageType - content: Buffer + buffer: Buffer } diff --git a/src/utils.ts b/src/utils.ts index 5580b63..fabcf95 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -49,3 +49,11 @@ export const formatText = (text: string, color: string) => { export const getMimeType = (type: ImageType = 'png'): string => { return imageMimeType[type] || imageMimeType.png } + +export const getBackground = (background: string | string[]) => { + if (typeof background === 'string') { + return background + } + const rdm = Math.floor(Math.random() * background.length) + return background[rdm] +} diff --git a/tsconfig.json b/tsconfig.json index fb587a1..02dec09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "noEmitOnError": true }, "include": ["./src"], - "exclude": ["dist", "node_modules"], + "exclude": ["dist", "node_modules"] }