文章明明已经公开,为什么仍然 404:Bun.Glob 的隐藏文件规则

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

博客压测脚本先从首页发现文章链接,再逐个请求。它遇到一篇 .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: publishedaccess: 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 排障.md

frontmatter 中的标题和 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”,我会先查扫描结果里有没有这篇文章,然后再查公开筛选、路由生成和部署产物。这次真正需要改的只有文件名,前提是先找到文件在哪一步消失了。