在本地用 Markdown 写文章时,我们常用相对路径引用其他文章,例如:

1
[Hexo永久链接最佳实践:终极方案与优化指南](./Hexo永久链接最佳实践:终极方案与优化指南.md)

但 Hexo 在生成静态页面时,会将上述语法直接转换为:

1
<a href="./Hexo永久链接最佳实践:终极方案与优化指南.md" target="_blank"></a>

由于浏览器无法解析 .md 文件路径,导致超链接失效。

解决方案

在 Hexo 项目根目录下创建 scripts/fix-relative-links.js 文件,并添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185

// 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属性替换为对应文件真正的跳转路径。

替换逻辑

  1. 正则匹配 <a href="... .md"> 标签
  2. 解码 URL 编码的路径
  3. 解析相对路径为绝对路径
  4. 在文章列表中查找目标文章
  5. 使用 permalink || path 构建最终 URL
  6. 确保为绝对 URL(拼接 config.url)