#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { glob } from "glob";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONTENT_DIR = path.join(process.cwd(), "src/content");
const POSTS_DIR = path.join(CONTENT_DIR, "posts");
/**
* 获取所有 markdown 文件
*/
async function getAllMarkdownFiles() {
try {
const pattern = path.join(POSTS_DIR, "**/*.md").replace(/\\/g, "/");
return await glob(pattern);
} catch (error) {
console.error("获取 markdown 文件失败:", error.message);
return [];
}
}
/**
* 处理单个 Markdown 文件
*/
async function processMarkdownFile(filePath) {
let content = fs.readFileSync(filePath, "utf-8");
let originalContent = content;
let hasChanges = false;
let changedCount = 0;
const replacements = [];
// 1. 处理 YAML frontmatter 中的 image 字段
const yamlImageRegex = /^---[\s\S]*?image:\s*(?:['"]([^'"]+)['"]|([^\s\n]+))[\s\S]*?^---/m;
let match = yamlImageRegex.exec(content);
if (match) {
const fullMatch = match[0];
// 捕获组1是带引号的,捕获组2是不带引号的
const imagePath = match[1] || match[2];
if (imagePath && (imagePath.includes(" ") || imagePath.includes("%20") || imagePath.includes(",") || hasExtraDots(imagePath))) {
// 当路径包含空格、%20、逗号或额外点时处理
const result = await handleImageRename(filePath, imagePath);
if (result) {
// 替换 YAML 中的路径
// 注意:这里需要小心替换,只替换 image: 后的部分
// 简单起见,我们对整个 content 做字符串替换,但要注意唯一性
// 更稳妥的方式是替换 match[0] 中的 imagePath
// 这里我们先收集替换信息,最后统一替换,或者直接替换 content
// 为了防止多次替换导致错乱,我们记录下来
replacements.push({
original: imagePath,
new: result
});
}
}
}
// 2. 处理 Markdown 图片语法 
// 修复:支持 URL 中包含一层括号,例如 image(1).png
const markdownImageRegex = /!\[.*?\]\(((?:[^()]+|\([^()]*\))+)\)/g;
while ((match = markdownImageRegex.exec(content)) !== null) {
const fullUrl = match[1];
// 去除可能的 title 部分
let url = fullUrl;
const titleMatch = url.match(/^(\S+)\s+["'].*["']$/);
if (titleMatch) {
url = titleMatch[1];
}
// 去除 <> 包裹
if (url.startsWith('<') && url.endsWith('>')) {
url = url.slice(1, -1);
}
if (url.includes(" ") || url.includes("%20") || url.includes(",") || hasExtraDots(url)) {
const result = await handleImageRename(filePath, url);
if (result) {
replacements.push({
original: url, // 这里需要替换的是原始引用的 url 部分 (不含 title)
new: result,
fullMatch: fullUrl // 也可以用于定位
});
}
}
}
// 3. 处理 HTML img 标签
const htmlImageRegex = /
]+src=["']([^"']+)["'][^>]*>/gi;
while ((match = htmlImageRegex.exec(content)) !== null) {
const url = match[1];
if (url.includes(" ") || url.includes("%20") || url.includes(",") || hasExtraDots(url)) {
const result = await handleImageRename(filePath, url);
if (result) {
replacements.push({
original: url,
new: result
});
}
}
}
// 执行替换
if (replacements.length > 0) {
// 按照 original 长度倒序排序,避免部分替换
replacements.sort((a, b) => b.original.length - a.original.length);
// 去重
const uniqueReplacements = new Map();
replacements.forEach(item => {
if (!uniqueReplacements.has(item.original)) {
uniqueReplacements.set(item.original, item.new);
}
});
for (const [original, newPath] of uniqueReplacements) {
// 全局替换
// 需要转义正则特殊字符
const escapedOriginal = original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedOriginal, 'g');
if (content.match(regex)) {
content = content.replace(regex, newPath);
hasChanges = true;
changedCount++;
console.log(` 🔄 更新引用: "${original}" -> "${newPath}"`);
}
}
}
if (hasChanges) {
fs.writeFileSync(filePath, content, "utf-8");
console.log(`💾 已保存文件: ${path.relative(process.cwd(), filePath)} (更新了 ${changedCount} 处引用)`);
}
}
/**
* 处理图片重命名
* @returns {string|null} 新的相对路径,如果没有变化或失败则返回 null
*/
async function handleImageRename(markdownPath, imagePath) {
// 1. 解析绝对路径
let absolutePath = null;
const markdownDir = path.dirname(markdownPath);
// 解码 URL (处理 %20)
let decodedPath = imagePath;
try {
decodedPath = decodeURIComponent(imagePath);
} catch (e) {
// ignore
}
if (decodedPath.startsWith("http://") || decodedPath.startsWith("https://")) {
return null;
}
if (decodedPath.startsWith("/")) {
// 绝对路径,通常相对于 public 或 src (这里假设是 src/content/assets 或者 public)
// Fuwari 项目结构似乎图片在 src/content/assets
// 如果以 / 引用,可能很难确定根目录,暂且跳过,除非它是相对于 content 的
// 观察现有代码,normalizePath 忽略了 / 开头的。
// 但用户提到 "寻找MarkDown中的相对路径的图片",所以我们可以只关注相对路径
return null;
} else {
// 相对路径
const candidates = [decodedPath];
if (decodedPath !== imagePath) {
candidates.push(imagePath);
}
for (const candidate of candidates) {
const candidateAbs = path.resolve(markdownDir, candidate);
if (fs.existsSync(candidateAbs)) {
absolutePath = candidateAbs;
break;
}
}
}
if (!fs.existsSync(absolutePath)) {
console.warn(` ⚠️ 图片不存在 (跳过): ${decodedPath}`);
return null;
}
// 2. 生成新文件名 (删除空格、逗号、以及除了扩展名点之外的其他点)
const dir = path.dirname(absolutePath);
const filename = path.basename(absolutePath);
const ext = path.extname(filename);
const nameWithoutExt = path.basename(filename, ext);
// 移除空格、%20、逗号、以及点
const newName = nameWithoutExt
.replace(/\s+/g, "")
.replace(/%20/g, "")
.replace(/,/g, "")
.replace(/\./g, ""); // 移除文件名主体中的所有点
const newFilename = newName + ext;
if (filename === newFilename) {
return null; // 没有变化
}
const newAbsolutePath = path.join(dir, newFilename);
// 3. 重命名文件
try {
if (fs.existsSync(newAbsolutePath)) {
// 如果目标文件已存在
// 比较内容是否一致?或者直接覆盖?或者跳过?
// 简单起见,如果目标存在且不是同一个文件(虽然文件名不同,但在某些不区分大小写系统可能冲突,不过这里是去除空格,应该不同)
// 假设用户希望合并
console.warn(` ⚠️ 目标文件已存在: ${path.basename(newAbsolutePath)} (将使用已存在的文件)`);
// 如果源文件存在,删除源文件 (因为它被合并到了目标文件)
// 但为了安全,也许我们不应该删除,只是更新引用?
// 还是说:如果A.png和A .png都存在,我们将A .png重命名为A.png,这会覆盖A.png吗?
// fs.renameSync 会覆盖。
// 既然是"del-space",如果去空格后的文件已存在,说明可能已经有一份了。
// 我们可以认为它们是同一张图(或者用户不介意),直接使用新文件名,并保留(或覆盖)
// 安全起见,如果目标存在,我们不覆盖,只是更新引用指向它。
// 但是源文件怎么办?如果不删除,就是"clean"脚本的事了。
// 用户说 "同步修改原图的文件名",implies rename.
// 如果我 rename A to B, and B exists. rename fails or overwrites.
// 让我们尝试 rename,如果报错再处理.
} else {
fs.renameSync(absolutePath, newAbsolutePath);
console.log(` ✨ 重命名图片: "${filename}" -> "${newFilename}"`);
}
} catch (error) {
console.error(` ❌ 重命名失败: ${error.message}`);
return null;
}
// 4. 返回新的相对路径
// 保持原来的相对路径结构,只改变文件名
// imagePath 可能是 ../assets/foo bar.png
// 我们需要返回 ../assets/foobar.png
// 重新构建引用路径
// 使用 path.dirname(imagePath) 可能会受到 OS 分隔符影响
// 我们简单地替换文件名
// 注意:imagePath 可能是 encoded 的 (%20),也可能是 raw space
// 我们返回的新路径应该是不包含空格的,通常不需要 encode
// 获取 imagePath 的目录部分
// 简单的字符串操作:找到最后一个 / 或 \
const lastSeparatorIndex = Math.max(imagePath.lastIndexOf('/'), imagePath.lastIndexOf('\\'));
let newReferencePath;
if (lastSeparatorIndex === -1) {
newReferencePath = newFilename;
} else {
newReferencePath = imagePath.substring(0, lastSeparatorIndex + 1) + newFilename;
}
return newReferencePath.replace(/%20/g, "");
}
/**
* 检查路径中是否包含除了扩展名点之外的其他点(仅检查文件名部分)
*/
function hasExtraDots(imagePath) {
try {
// 解码
let decodedPath = imagePath;
try {
decodedPath = decodeURIComponent(imagePath);
} catch (e) {
// ignore
}
// 获取文件名
const filename = path.basename(decodedPath);
// 如果是以点开头的文件(如 .gitignore),忽略
if (filename.startsWith('.')) {
// 如果只有开头的点,没有其他点,则是 false
// 如果有其他点,如 .foo.bar,则是 true
const parts = filename.split('.');
return parts.length > 2;
}
// 正常文件名
const ext = path.extname(filename);
const nameWithoutExt = path.basename(filename, ext);
// 检查 nameWithoutExt 是否包含点
return nameWithoutExt.includes('.');
} catch (error) {
return false;
}
}
async function main() {
console.log("🔍 开始扫描 Markdown 文件中的空格图片路径...");
if (!fs.existsSync(POSTS_DIR)) {
console.error(`❌ Posts 目录不存在: ${POSTS_DIR}`);
return;
}
const files = await getAllMarkdownFiles();
console.log(`📄 找到 ${files.length} 个 Markdown 文件`);
for (const file of files) {
// console.log(`检查: ${path.relative(process.cwd(), file)}`);
await processMarkdownFile(file);
}
console.log("✅ 完成!");
}
main().catch(err => {
console.error("❌ 发生错误:", err);
process.exit(1);
});