@investravis.com/hexo-goose-builder
Version:
An exploratory plugin that aims to introduce a theme builder for Hexo, which supports modular development in the theme building process and supports popular ESM scripts and TailwindCSS, etc.
430 lines (374 loc) • 15.6 kB
JavaScript
const path = require('path');
const fs = require('fs-extra');
const postcss = require('postcss');
const ProgressLogger = require('../../progress-logger');
/**
* 资源复制插件配置接口
* @typedef {Object} AssetCopierOptions
* @property {string} assetsPath - 资源输出目录
* @property {boolean} useHash - 是否使用哈希值
* @property {Object} hashOptions - 哈希选项
* @property {boolean} hashOptions.append - 是否在文件名后附加哈希
* @property {string} hashOptions.method - 哈希算法
* @property {string} [publicPath] - 资源的公共访问路径(例如:'/css/assets')
* @property {boolean} [cleanBeforeBuild] - 是否在构建前清理 assets 目录,默认为 true
* @property {Object} [hexoConfig] - Hexo配置对象,用于获取调试设置
*/
/**
* 格式化文件大小
* @param {number} bytes
* @returns {string}
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 简单的单行进度显示
* @param {number} current 当前进度
* @param {number} total 总数
* @param {string} message 消息
*/
function showSimpleProgress(current, total, message = '') {
if (total === 0) return;
const percent = Math.round((current / total) * 100);
const barLength = 30;
const filledLength = Math.round((barLength * current) / total);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
const progressText = `[Asset Copier] [${bar}] ${percent}% (${current}/${total}) ${message}`;
// 使用 \r 回到行首覆盖之前的内容
process.stdout.write('\r' + progressText);
// 如果完成了,换行
if (current === total) {
process.stdout.write('\n');
}
}
/**
* 创建日志函数
* @param {boolean} debug - 是否开启调试模式
* @returns {Object} 日志函数对象
*/
function createLogger(debug = false) {
if (debug) {
// 调试模式:使用完整日志
return {
log: console.log,
warn: console.warn,
error: console.error,
progressLogger: null,
simpleProgress: showSimpleProgress,
isDebug: true
};
} else {
// 非调试模式:使用进度条和滚动日志
const progressLogger = new ProgressLogger('Asset Copier');
return {
log: (message) => {
// 在非调试模式下简化日志记录
// 只记录重要信息,不显示详细的文件处理信息
},
warn: (message) => {
// 警告信息仍然记录
console.warn(message);
},
error: (message) => {
// 错误信息仍然记录
console.error(message);
},
progressLogger,
simpleProgress: showSimpleProgress,
isDebug: false
};
}
}
/**
* 清理目录
* @param {string} dir 要清理的目录
* @param {Object} logger 日志对象
* @returns {Promise<void>}
*/
async function cleanDirectory(dir, logger) {
try {
// 检查目录是否存在
if (await fs.pathExists(dir)) {
// 获取目录信息
const stats = await fs.stat(dir);
if (!stats.isDirectory()) {
throw new Error(`路径 ${dir} 不是一个目录`);
}
// 读取目录内容
const files = await fs.readdir(dir);
logger.log(`[Asset Copier] 清理目录 ${dir}`);
if (files.length > 0) {
logger.log(`[Asset Copier] 发现 ${files.length} 个文件/目录待清理`);
}
// 删除所有文件和子目录
if (files.length > 0) {
await Promise.all(files.map(async file => {
const fullPath = path.join(dir, file);
await fs.remove(fullPath);
// 只在debug模式下显示每个删除的文件
if (logger.isDebug) {
logger.log(`[Asset Copier] 已删除: ${fullPath}`);
}
}));
}
logger.log(`[Asset Copier] 目录清理完成: ${dir}`);
} else {
// 如果目录不存在,创建它
await fs.ensureDir(dir);
logger.log(`[Asset Copier] 创建目录: ${dir}`);
}
} catch (error) {
logger.error(`[Asset Copier] ❌ 清理目录失败: ${error.message}`);
throw error;
}
}
/**
* 从CSS内容中提取资源路径
* @param {string} content - CSS内容
* @returns {string[]} - 资源路径数组
*/
function extractAssetPaths(content) {
const patterns = [
// 标准 url() 格式,支持单引号、双引号或无引号
/url\(['"]?([^'"()]+?)['"]?\)/g,
// 字体文件格式,支持 src: url() 和 src: local()
/src:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
// 字体本地字体引用(local函数)
/src:\s*local\(['"]?([^'"()]+?)['"]?\)/g,
// 其他可能的资源引用格式
/content:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
/list-style-image:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
/cursor:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
/mask(?:-image)?:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
/-webkit-mask(?:-image)?:\s*url\(['"]?([^'"()]+?)['"]?\)/g,
/filter:\s*url\(['"]?([^'"()]+?)['"]?\)/g
];
const assets = new Set();
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
// 获取第一个捕获组的值
const assetPath = match[1];
// 确保路径存在且不为空
if (assetPath && assetPath.trim()) {
// 跳过数据URL、绝对URL和CSS关键字
if (!assetPath.startsWith('data:') &&
!assetPath.startsWith('http://') &&
!assetPath.startsWith('https://') &&
!assetPath.startsWith('//') &&
!isCSSkeyword(assetPath.trim())) {
assets.add(assetPath.trim());
}
}
}
}
return Array.from(assets);
}
/**
* 检查是否为CSS关键字(不需要处理的值)
* @param {string} value - 要检查的值
* @returns {boolean} - 是否为CSS关键字
*/
function isCSSkeyword(value) {
const keywords = [
'none', 'initial', 'inherit', 'unset', 'revert', 'auto',
'transparent', 'currentColor', 'inherit',
// 颜色关键字
'black', 'white', 'red', 'green', 'blue', 'yellow', 'orange', 'purple',
// 渐变函数
'linear-gradient', 'radial-gradient', 'conic-gradient',
'repeating-linear-gradient', 'repeating-radial-gradient'
];
// 检查是否为关键字
if (keywords.includes(value.toLowerCase())) {
return true;
}
// 检查是否为函数调用(如gradient函数等)
if (/^[a-zA-Z-]+\s*\(/.test(value)) {
return true;
}
// 检查是否为颜色值
if (/^#[0-9a-fA-F]{3,8}$/.test(value) ||
/^rgb\s*\(/.test(value) ||
/^rgba\s*\(/.test(value) ||
/^hsl\s*\(/.test(value) ||
/^hsla\s*\(/.test(value)) {
return true;
}
return false;
}
/**
* 创建 PostCSS 资源复制插件
* @param {AssetCopierOptions} opts
*/
module.exports = (opts = {}) => {
const options = {
assetsPath: './assets',
useHash: true,
hashOptions: {
append: true,
method: 'sha256'
},
publicPath: '/css/assets', // 默认公共访问路径
cleanBeforeBuild: true, // 默认在构建前清理目录
hexoConfig: null, // Hexo配置对象
...opts
};
// 检查是否开启调试模式
const isDebugMode = options.hexoConfig &&
options.hexoConfig.theme_builder &&
options.hexoConfig.theme_builder.debug === true;
// 创建日志对象
const logger = createLogger(isDebugMode);
// 用于跟踪是否已经清理过目录
let hasCleanedDir = false;
return {
postcssPlugin: 'postcss-asset-copier',
async Once(root, { result }) {
const from = result.opts.from;
if (!from) {
logger.warn('[Asset Copier] 没有提供源文件路径,跳过资源复制');
return;
}
// 确保目标目录存在并清理
const targetDir = path.resolve(process.cwd(), options.assetsPath);
if (options.cleanBeforeBuild && !hasCleanedDir) {
await cleanDirectory(targetDir, logger);
hasCleanedDir = true;
}
const assets = extractAssetPaths(root.toString());
let totalAssets = assets.length;
let successCount = 0;
let failCount = 0;
// 只在有资源时显示详细信息,或在调试模式下显示
if (totalAssets > 0 || logger.isDebug) {
logger.log('\n[Asset Copier] ====== 开始处理文件 ======');
logger.log(`[Asset Copier] 源文件: ${from}`);
logger.log(`[Asset Copier] 输出目录: ${options.assetsPath}`);
logger.log(`[Asset Copier] 公共访问路径: ${options.publicPath}`);
logger.log(`[Asset Copier] 哈希模式: ${options.useHash ? '启用' : '禁用'}`);
logger.log(`[Asset Copier] 检测到 ${totalAssets} 个资源引用`);
}
// 只在有资源时才显示进度条
if (totalAssets > 0) {
// 初始化进度条
if (logger.progressLogger) {
logger.progressLogger.setTotal(totalAssets);
logger.progressLogger.updateProgress(0, `开始处理 ${totalAssets} 个资源`);
} else if (!logger.isDebug) {
// 使用简单进度条作为备选
logger.simpleProgress(0, totalAssets, '开始处理...');
}
}
// 处理所有资源 - 使用串行处理以便更好地显示进度
for (let index = 0; index < assets.length; index++) {
const assetPath = assets[index];
try {
const sourcePath = path.resolve(path.dirname(from), assetPath);
logger.log(`\n[Asset Copier] 处理资源 [${index + 1}/${totalAssets}]: ${assetPath}`);
logger.log(`[Asset Copier] 源路径: ${sourcePath}`);
// 更新进度条 - 确保只在有资源时更新
if (logger.progressLogger && totalAssets > 0) {
logger.progressLogger.updateProgress(index + 1, `处理中: ${path.basename(assetPath)}`);
} else if (!logger.isDebug && totalAssets > 0) {
logger.simpleProgress(index + 1, totalAssets, `处理中: ${path.basename(assetPath)}`);
}
// 检查源文件是否存在
if (!await fs.pathExists(sourcePath)) {
logger.warn(`[Asset Copier] ❌ 资源未找到: ${sourcePath}`);
failCount++;
// 更新进度条状态
if (logger.progressLogger && totalAssets > 0) {
logger.progressLogger.updateProgress(index + 1, `❌ ${path.basename(assetPath)} - 未找到`);
} else if (!logger.isDebug && totalAssets > 0) {
logger.simpleProgress(index + 1, totalAssets, `❌ ${path.basename(assetPath)} - 未找到`);
}
continue;
}
// 获取文件信息
const stats = await fs.stat(sourcePath);
logger.log(`[Asset Copier] 文件大小: ${formatFileSize(stats.size)}`);
// 生成目标路径
const fileName = path.basename(assetPath);
let targetFileName = fileName;
// 如果启用哈希,生成文件哈希
if (options.useHash) {
const fileContent = await fs.readFile(sourcePath);
const hash = require('crypto')
.createHash(options.hashOptions.method)
.update(fileContent)
.digest('hex')
.slice(0, 8);
logger.log(`[Asset Copier] 生成的哈希值: ${hash}`);
const ext = path.extname(fileName);
const nameWithoutExt = path.basename(fileName, ext);
targetFileName = `${nameWithoutExt}${options.hashOptions.append ? '.' + hash : ''}${ext}`;
}
const targetPath = path.join(targetDir, targetFileName);
// 复制文件
await fs.copy(sourcePath, targetPath);
// 生成公共访问路径
const publicPath = path.posix.join(options.publicPath, targetFileName);
// 遍历所有声明,更新资源路径
root.walkDecls(decl => {
if (decl.value.includes(assetPath)) {
const escapedPath = assetPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const replaceRegex = new RegExp(`(url\\(['"]?)${escapedPath}(['"]?\\))`, 'g');
decl.value = decl.value.replace(replaceRegex, `$1${publicPath}$2`);
}
});
logger.log(`[Asset Copier] ✅ 复制成功`);
logger.log(`[Asset Copier] 目标路径: ${targetPath}`);
logger.log(`[Asset Copier] CSS中的引用路径: ${publicPath}`);
successCount++;
// 更新进度条状态
if (logger.progressLogger && totalAssets > 0) {
logger.progressLogger.updateProgress(index + 1, `✅ ${path.basename(assetPath)} (${formatFileSize(stats.size)})`);
} else if (!logger.isDebug && totalAssets > 0) {
logger.simpleProgress(index + 1, totalAssets, `✅ ${path.basename(assetPath)} (${formatFileSize(stats.size)})`);
}
} catch (error) {
logger.error(`[Asset Copier] ❌ 处理失败 ${assetPath}:`, error);
failCount++;
// 更新进度条状态
if (logger.progressLogger && totalAssets > 0) {
logger.progressLogger.updateProgress(index + 1, `❌ ${path.basename(assetPath)} - 处理失败`);
} else if (!logger.isDebug && totalAssets > 0) {
logger.simpleProgress(index + 1, totalAssets, `❌ ${path.basename(assetPath)} - 处理失败`);
}
}
}
// 完成处理
if (logger.progressLogger && totalAssets > 0) {
// 确保最终进度显示为100%
logger.progressLogger.current = logger.progressLogger.total;
logger.progressLogger.lastUpdateTime = 0; // 强制更新
logger.progressLogger.updateProgress(totalAssets, `处理完成`);
// 短暂暂停让用户看到100%状态
await new Promise(resolve => setTimeout(resolve, 100));
logger.progressLogger.complete(successCount, failCount, totalAssets);
} else if (!logger.isDebug && totalAssets > 0) {
// 使用简单进度条完成
logger.simpleProgress(totalAssets, totalAssets, '处理完成');
console.log(`✅ [Asset Copier] 处理完成: ${successCount}/${totalAssets} 成功 (${Math.round(successCount/totalAssets*100)}%)`);
} else {
// 调试模式或无资源时的简洁输出
if (totalAssets === 0) {
logger.log('[Asset Copier] 无需处理的资源');
} else {
logger.log('\n[Asset Copier] ====== 处理完成 ======');
logger.log(`[Asset Copier] 总资源数: ${totalAssets}`);
logger.log(`[Asset Copier] 成功: ${successCount}`);
logger.log(`[Asset Copier] 失败: ${failCount}`);
logger.log('[Asset Copier] ========================\n');
}
}
}
};
};
module.exports.postcss = true;