UNPKG

@nolebase/vitepress-plugin-thumbnail-hash

Version:

A VitePress plugin that scan and generate data with blurhash, thumbhash hashing algorithm for images, as well as a standalone component to render images with blurhash and thumbhash.

168 lines (164 loc) 6.34 kB
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { relative, join } from 'node:path'; import CanvasKitInit from 'canvaskit-wasm'; import { cyan, gray } from 'colorette'; import ora from 'ora'; import { rgbaToThumbHash, thumbHashToDataURL } from 'thumbhash'; import { glob } from 'tinyglobby'; import { normalizePath } from 'vite'; import { subtle } from 'uncrypto'; function binaryToBase64(binary) { return btoa(String.fromCharCode(...binary)); } async function digestUint8ArrayDataSha256(data) { const hashBuffer = await subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hashBuffer)); } async function hash(data, length = 10) { const hashResult = await digestUint8ArrayDataSha256(data); const hashBase64 = binaryToBase64(new Uint8Array(hashResult)); if (length > 0) return hashBase64.substring(0, length); return hashBase64; } function normalizeBase64(base64) { return base64.replace("/", "_").replace("+", "-").replace("=", "-"); } async function calculateThumbHashForFile(imageData) { const canvasKit = await CanvasKitInit(); const image = canvasKit.MakeImageFromEncoded(imageData); if (!image) throw new Error("Failed to make image from encoded data."); const width = image.width(); const height = image.height(); const scale = 100 / Math.max(width, height); const resizedWidth = Math.round(width * scale); const resizedHeight = Math.round(height * scale); const canvas = canvasKit.MakeCanvas(resizedWidth, resizedHeight); const context = canvas.getContext("2d"); context.drawImage(image, 0, 0, resizedWidth, resizedHeight); const pixels = context.getImageData(0, 0, resizedWidth, resizedHeight); const thumbHashBinary = rgbaToThumbHash(pixels.width, pixels.height, pixels.data); const thumbHashBase64 = binaryToBase64(thumbHashBinary); const thumbHashDataURL = await thumbHashToDataURL(thumbHashBinary); return { dataBase64: thumbHashBase64, dataUrl: thumbHashDataURL, width: resizedWidth, height: resizedHeight, originalWidth: width, originalHeight: height }; } function createThumbHashDataFromThumbHash(rootDir, assetDir, baseUrl, imageFileName, imageFullFileName, imageFullHash, imageFileHash, thumbHash) { const fileName = relative(rootDir, imageFileName); const thumbhashData = { ...thumbHash, assetFileName: normalizePath(relative(rootDir, imageFullFileName)), assetFullFileName: normalizePath(imageFullFileName), assetFullHash: imageFullHash, assetFileHash: imageFileHash, assetUrl: normalizePath(join(assetDir, fileName)), assetUrlWithBase: normalizePath(join(baseUrl, assetDir, fileName)) }; if (!thumbhashData.assetUrlWithBase.startsWith("/")) thumbhashData.assetUrlWithBase = `/${thumbhashData.assetUrlWithBase}`; return thumbhashData; } function getCacheDir(vitePressCacheDir) { return join(vitePressCacheDir, "@nolebase", "vitepress-plugin-thumbnail-hash", "thumbhashes"); } function getMapFilename(cacheDir) { return join(cacheDir, "map.json"); } async function exists(path) { try { await stat(path); return true; } catch (error) { if (!(error instanceof Error)) throw error; if (!("code" in error)) throw error; if (error.code !== "ENOENT") throw error; return false; } } async function mkdirIfNotExist(dir) { const targetDirExists = await exists(dir); if (targetDirExists) return; await mkdir(dir, { recursive: true }); } async function readCachedMapFile(path) { const targetPathExists = await exists(path); if (!targetPathExists) return {}; const content = await readFile(path); return JSON.parse(content.toString("utf-8")); } function ThumbnailHashImages() { return { name: "@nolebase/vitepress-plugin-thumbnail-hash/images", enforce: "pre", config() { return { optimizeDeps: { exclude: [ "@nolebase/vitepress-plugin-thumbnail-hash/client" ] }, ssr: { noExternal: [ "@nolebase/vitepress-plugin-thumbnail-hash" ] } }; }, async configResolved(config) { const root = config.root; const vitepressConfig = config.vitepress; const startsAt = Date.now(); const moduleNamePrefix = cyan("@nolebase/vitepress-plugin-thumbnail-hash/images"); const grayPrefix = gray(":"); const spinnerPrefix = `${moduleNamePrefix}${grayPrefix}`; const spinner = ora({ discardStdin: false, isEnabled: config.command === "serve" }); spinner.start(`${spinnerPrefix} Prepare to generate hashes for images...`); const cacheDir = getCacheDir(vitepressConfig.cacheDir); await mkdirIfNotExist(cacheDir); const thumbhashMap = await readCachedMapFile(getMapFilename(cacheDir)); spinner.text = `${spinnerPrefix} Searching for images...`; const files = await glob(`${root}/**/*.+(jpg|jpeg|png)`, { onlyFiles: true }); spinner.text = `${spinnerPrefix} Calculating thumbhashes for images...`; const thumbhashes = await Promise.all(files.map(async (file) => { const cacheHit = thumbhashMap[normalizePath(relative(root, file))]; if (cacheHit) return cacheHit; const readImageRawData = await readFile(file); const imageFullHash = await hash(readImageRawData, -1); const imageFileHash = normalizeBase64(imageFullHash.substring(0, 10)); const calculatedThumbhashData = await calculateThumbHashForFile(readImageRawData); return createThumbHashDataFromThumbHash( root, vitepressConfig.assetsDir, vitepressConfig.site.base, file, file, imageFullHash, imageFileHash, calculatedThumbhashData ); })); spinner.text = `${spinnerPrefix} Aggregating calculated thumbhash data...`; for (const thumbhash of thumbhashes) thumbhashMap[thumbhash.assetFileName] = thumbhash; spinner.text = `${spinnerPrefix} Writing thumbhash data to cache...`; await writeFile(getMapFilename(cacheDir), JSON.stringify(thumbhashMap, null, 2)); const elapsed = Date.now() - startsAt; spinner.succeed(`${spinnerPrefix} Done. ${gray(`(${elapsed}ms)`)}`); } }; } export { ThumbnailHashImages }; //# sourceMappingURL=index.mjs.map