在本地用 Markdown 写文章时,我们常用相对路径引用其他文章,例如:
[Hexo永久链接最佳实践:终极方案与优化指南](./Hexo永久链接最佳实践:终极方案与优化指南.md)
但 Hexo 在生成静态页面时,会将上述语法直接转换为:
<a href="./Hexo永久链接最佳实践:终极方案与优化指南.md" target="_blank"></a>
由于浏览器无法解析 .md 文件路径,导致超链接失效。
解决方案
在 Hexo 项目根目录下创建 scripts/fix-relative-links.js 文件,并添加以下代码:
// scripts/fix-relative-links.js
const path = require('path');
const fs = require('fs');
const glob = require('glob');
// 存储所有文章的数据
let allPostsCache = null;
let postsCache = [];
// 获取所有文章的函数
function getAllPosts(hexoInstance) {
const posts = hexoInstance.locals.get('posts');
if (posts && posts.length > 0) {
return posts.toArray().map(post => ({
source: post.source,
path: post.path,
permalink: post.permalink
}));
}
return null;
}
// 处理链接的核心函数
function replaceLinks(content, currentSource, config, posts) {
if (!content || !posts || posts.length === 0) return null;
const normalize = p => p.replace(/\\/g, '/');
// 匹配 HTML 中的 <a> 标签,href 包含 .md
const linkRegex = /<a\s+(?:[^>]*?\s+)?href="([^"]*\.md)"([^>]*)>([\s\S]*?)<\/a>/gi;
let hasChanged = false;
let newContent = content.replace(linkRegex, (match, href, attrs, text) => {
// 跳过外部链接
if (href.includes('://')) return match;
// 解析相对路径
const currentDir = path.dirname(currentSource);
const decodedPath = decodeURIComponent(href);
const relPath = path.normalize(path.join(currentDir, decodedPath));
const targetSourcePath = path.join(config.source_dir, relPath);
// 在缓存的文章中查找目标
const targetPost = posts.find(p => {
const postSourcePath = path.join(config.source_dir, p.source);
return normalize(postSourcePath) === normalize(targetSourcePath);
});
if (targetPost) {
hasChanged = true;
// 优先使用自定义 permalink,否则使用 Hexo 生成的默认链接
let finalUrl = targetPost.permalink || targetPost.path;
if (!finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) {
const siteRoot = config.root || '/';
finalUrl = siteRoot + finalUrl;
}
// 确保是绝对 URL
if (!finalUrl.startsWith('http')) {
finalUrl = config.url.replace(/\/$/, '') + finalUrl;
}
// 保留原有属性,只替换 href
return `<a href="${finalUrl}"${attrs}>${text}</a>`;
}
return match; // 未找到则保留原链接
});
return hasChanged ? newContent : null;
}
// 在 before_generate 时尝试预加载(适用于 hexo server)
hexo.extend.filter.register('before_generate', function () {
const posts = getAllPosts(this);
if (posts && posts.length > 0) {
allPostsCache = posts;
hexo.log.info('fix-relative-links: loaded ' + allPostsCache.length + ' posts in before_generate');
}
});
// 在 after_post_render 中处理链接
hexo.extend.filter.register('after_post_render', function (data) {
const { config } = this;
// 收集文章信息(用于 after_generate 处理文件)
postsCache.push({
source: data.source,
path: data.path,
content: data.content,
permalink: data.permalink
});
// 获取最新的文章列表
const posts = getAllPosts(this);
if (posts && posts.length > 0) {
allPostsCache = posts;
}
// 尝试处理内容(hexo server 模式下可以实时处理)
const newContent = replaceLinks(data.content, data.source, config, allPostsCache || postsCache);
if (newContent) {
data.content = newContent;
hexo.log.info('Updated links in after_post_render: ' + data.source);
}
return data;
});
// 在 before_exit 中统一处理文件(适用于 hexo generate)
// before_exit 在 hexo generate 完成且文件写入磁盘后触发
hexo.extend.filter.register('before_exit', function () {
const { config } = this;
// 获取完整的文章列表 - 总是尝试从 locals 获取最新的
let posts = getAllPosts(this);
// 如果 locals 没有或数量不足,尝试从 hexo.locals 获取
if (!posts || posts.length < 10) {
try {
const hexoPosts = hexo.locals.get('posts');
if (hexoPosts && hexoPosts.length > 0) {
posts = hexoPosts.toArray().map(post => ({
source: post.source,
path: post.path,
permalink: post.permalink
}));
hexo.log.info('fix-relative-links: loaded ' + posts.length + ' posts from hexo.locals');
}
} catch (e) {
// 忽略错误
}
}
// 如果还是没有,使用 postsCache 中的文章
if (!posts || posts.length === 0) {
if (postsCache.length > 0) {
posts = postsCache.map(p => ({ source: p.source, path: p.path, permalink: p.permalink }));
hexo.log.info('fix-relative-links: using ' + posts.length + ' cached posts from postsCache');
} else {
// 没有文章时静默返回,避免在 hexo server 启动时显示警告
return;
}
}
allPostsCache = posts;
hexo.log.info('fix-relative-links: processing with ' + posts.length + ' posts, postsCache has ' + postsCache.length + ' items');
// 处理所有 HTML 文件
const htmlFiles = glob.sync('**/*.html', { cwd: this.public_dir });
let updatedCount = 0;
htmlFiles.forEach(file => {
const publicPath = path.join(this.public_dir, file);
if (!fs.existsSync(publicPath)) return;
let fileContent = fs.readFileSync(publicPath, 'utf-8');
// 只处理包含 .md 链接的文件
if (!fileContent.includes('.md')) return;
// 找到对应的 source 文件
const post = posts.find(p => p.path === file || file.endsWith(p.path)) ||
postsCache.find(p => p.path === file || file.endsWith(p.path));
const sourcePath = post ? post.source : ('_posts/' + path.basename(file, '.html') + '.md');
const newContent = replaceLinks(fileContent, sourcePath, config, posts);
if (newContent) {
fs.writeFileSync(publicPath, newContent, 'utf-8');
hexo.log.info('Updated links in: ' + file);
updatedCount++;
}
});
if (updatedCount > 0) {
hexo.log.info('fix-relative-links: updated ' + updatedCount + ' files');
}
// 清空缓存
postsCache = [];
});
在hexo根据markdown文章生成网页之后,匹配<a>标签中href是以 .md 结尾的文件,将原本的href属性替换为对应文件真正的跳转路径。
替换逻辑
- 正则匹配
<a href="... .md">标签 - 解码 URL 编码的路径
- 解析相对路径为绝对路径
- 在文章列表中查找目标文章
- 使用 permalink || path 构建最终 URL
- 确保为绝对 URL(拼接 config.url)