博客压测脚本先从首页发现文章链接,再逐个请求。它遇到一篇 .NET 排障文章时停了下来,地址返回 404。
最初的判断是“失效内链”。但我去检查了文章:文件存在,发布状态和公开权限都没有问题,链接中的 slug 也正确。继续改链接没有意义,需要先确认构建时到底有没有读到这个文件。
最后发现,文件名的第一个字符就是原因。
.NET 在标题里正常,在文件名里可能被跳过
那篇文章原来的文件名是:
.NET 6 在 Linux Docker 中连接 SQL Server 2005 与 2008 R2 的 PreLogin 排障.md内容扫描使用:
const glob = new Bun.Glob('**/*');
for await (const path of glob.scan({
cwd: contentDir,
onlyFiles: true,
})) {
// 后续读取 Markdown 和发布元数据。
}Bun.Glob 文档中,扫描选项 dot 默认为 false,即跳过点开头的隐藏文件。这里看的是文件名规则,不要求先在 Windows 文件属性里勾选“隐藏”。
那轮对同一个内容目录做扫描对照,结果是:
dot: false → 290 个文件,不包含目标文章
dot: true → 296 个文件,包含目标文章文件总数只属于当时的目录快照;决定性的证据是目标文件是否出现在扫描结果中。六个文件的差额也不能直接叫作“漏了六篇公开文章”,其中还可能包含其他隐藏文件。
文件在第一步就被跳过,后面的 frontmatter 解析、公开状态筛选和 generateStaticParams() 根本没有机会处理它。所以 status: published 和 access: public 都写对了,也无法让页面生成。
与此同时,另一篇正文中已经有指向它的链接。构建成功与所有站内链接都有目标页面,是两回事。
用两个文件就能复现
不必把整个博客带进来。下面的 Bun 脚本创建独立临时目录,放入两个 Markdown,分别扫描;结束后删除它自己创建的目录:
import { mkdtemp, rmdir, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const directory = await mkdtemp(join(tmpdir(), 'bun-glob-demo-'));
try {
await Bun.write(join(directory, '普通文章.md'), '# 普通文章');
await Bun.write(join(directory, '.NET 排障.md'), '# .NET 排障');
for (const dot of [false, true]) {
const files = [];
for await (const file of new Bun.Glob('**/*.md').scan({
cwd: directory,
onlyFiles: true,
dot,
})) {
files.push(file);
}
console.log({ dot, files: files.sort() });
}
} finally {
await unlink(join(directory, '普通文章.md'));
await unlink(join(directory, '.NET 排障.md'));
await rmdir(directory);
}保存为 glob-demo.ts,用 bun glob-demo.ts 执行。dot: false 只找到普通文章,dot: true 才会包含 .NET 排障.md。
这个复现也排除了 HTTP 缓存、Next 路由匹配和反向代理。即使没有启动 Web 服务,漏扫已经发生了。
修复文件名,保留公开 URL
博客最终把文件名改为:
NET 6 在 Linux Docker 中连接 SQL Server 2005 与 2008 R2 的 PreLogin 排障.mdfrontmatter 中的标题和 slug 保持原样。页面 URL 来自 slug,而不是 Markdown 文件名,因此这次修复不需要改公开链接,也不需要新增重定向。
当时重建后,目标路径进入了预渲染清单,并生成了 HTML 和 RSC 产物。这个结果比只确认“文件已经改名”更有用:它说明文章已经重新进入内容处理链路。
给生产扫描器加 dot: true 也是一个可选方案。是否采用,取决于内容目录是否允许点开头的文章。这个仓库选择禁止这类文件名,让文件更容易被编辑器、扫描器和其他工具一致识别;并没有把 Glob 的默认规则当成 Bun 的故障。
检查器反而必须扫描隐藏文件
为了避免再次发生,新增的文件名检查主动使用 dot: true:
const markdownFiles = new Bun.Glob('**/*.md');
for await (const relativePath of markdownFiles.scan({
cwd: postsDirectory,
dot: true,
onlyFiles: true,
})) {
const normalizedPath = relativePath.replaceAll('\\', '/');
const fileName = normalizedPath.split('/').at(-1) ?? '';
if (fileName.startsWith('.')) {
console.error('文章 Markdown 文件名不得以 . 开头:', relativePath);
process.exitCode = 1;
}
}这里的 postsDirectory 是文章目录的绝对路径。检查器只扫描 Markdown,发现违规名称后以非零状态退出。它必须先看到那些生产扫描器可能忽略的文件,才能报告问题;照抄生产扫描参数,检查就会和加载器一起漏掉它们。
文件名检查独立于摘要等内容审计,并在内容检查命令中一起执行。今后再遇到“文件存在但页面 404”,我会先查扫描结果里有没有这篇文章,然后再查公开筛选、路由生成和部署产物。这次真正需要改的只有文件名,前提是先找到文件在哪一步消失了。