Hugo 在构建运行时判断哪些页面应当渲染。文章元数据提供发布时间与草稿状态,项目配置和命令参数提供包含规则,构建时钟提供判断所用的时间。生成目录一旦部署,在下次产物替换之前不会自行变化。因此,日期走到午夜不等于触发发布;没有写 draft: true 的文件,也不会自动被当成草稿。这两个边界决定了排期内容能否按预期上线。

元数据和构建选项分别起作用

在默认规则下,标记为 draft: true 的页面不进入产物;发布时间在未来的页面也会被排除,已经过了失效时间的页面同样如此。Hugo 根据 front matter 配置解析各类日期,显式的 publishDate 可以不同于页面显示的 date。对必须精确到某个边界的排期,应使用包含时区的完整时间戳,避免本机和构建服务器把同一个日期解释为不同时间。

--buildDrafts 包含草稿,--buildFuture 包含未来内容,--buildExpired 包含已失效内容,短选项分别是 -D、-F 和 -E。这些开关处理不同条件:一篇发布时间尚未到达的草稿,需要同时允许草稿和未来内容才能提前进入产物。项目配置或环境配置也能启用相同行为,所以只看文章里的标记不足以判断实际发布结果,还必须检查本次构建的有效选项。

本站明确设置了 buildFuture: true,用于保留包含未来日期文章的既有公开目录。因此,本站这些文章的日期不是保密截止时间,也不能用 Hugo 的默认排期规则推断它们尚未公开。修改发布策略之前,需要把配置和预期 URL 清单一起比较,否则关闭一个看似普通的开关,就可能让大量已经存在的地址消失。介绍默认机制与解释本站事实,应当采用各自对应的证据。

预览产物仍然可以被公开

预览开关只改变输出包含什么,不会给产物增加禁止部署的属性。如果流水线上传了用 -D -F 生成的目录,草稿和未来页面一样可以被访问。因此,预览应使用独立输出位置,正式发布则根据计划中的选项重新构建。Git 发布标签只是标识源码历史,既不会改变 Hugo 的包含规则,也不能证明某个生成目录已经经过审阅。交付记录需要关联源码版本、构建选项和实际产物。

按时发布还需要在发布时间之后执行新的构建与部署。反过来,把已经公开的页面重新标记为草稿,也不会撤回服务器上的旧 HTML 或缓存副本。需要重建、替换被服务的产物,再检查公共地址。Hugo 通常不会自动删除输出目录里的所有遗留文件,因此独立的新目录或合适的清洁构建,是证明被排除页面确实不存在的必要步骤。导航里不显示链接,远不足以证明内容已经下线。

坏掉的样子

把预览目录覆盖到发布目录,可能让一篇后来恢复为草稿的文章仍能通过直接地址打开,而索引里已经看不到它。排查应检查生成文件、订阅源、站点地图以及真正对外的页面,区分索引隐藏和文件移除。另一个容易遗漏的规则是:章节页面标记为草稿,并不会自动把所有子页面也变成草稿。若整个子树需要排除,应设置具体子页元数据,或依照文档使用级联配置。

时区差异则可能让两台构建机器从同一个提交生成不同页面。边界测试应固定 Hugo 版本和构建时钟,并记录生效参数。不要在尚未弄清日期解析规则、环境时区和参数来源之前,为了让页面出现而随意改动发布日期。那样可能掩盖配置错误,还会改变订阅顺序和页面展示时间。首先使输入和时钟可重现,才能判断差异真正来自哪里。

快速验证

安装 Python 3 和 Hugo 0.165.0 后,可运行下面的隔离实验。它在临时项目中创建五篇页面,分别构建到八个独立目录,不修改当前仓库,也不执行部署。

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess

with TemporaryDirectory(prefix="hugo-publication-") as directory:
    root = Path(directory)
    (root / "content/posts").mkdir(parents=True)
    (root / "layouts/_default").mkdir(parents=True)
    (root / "hugo.yaml").write_text("baseURL: https://example.org/\n")
    (root / "layouts/index.html").write_text("<h1>Publication test</h1>")
    (root / "layouts/_default/list.html").write_text("{{ .Title }}")
    (root / "layouts/_default/single.html").write_text("{{ .Title }}")
    for name, extra in {
        "visible": "",
        "draft": "draft: true\n",
        "future": "publishDate: 2030-01-01T00:00:00Z\n",
        "future-draft": "draft: true\npublishDate: 2030-01-01T00:00:00Z\n",
        "expired": "expiryDate: 2025-12-01T00:00:00Z\n",
    }.items():
        (root / f"content/posts/{name}.md").write_text(
            f"---\ntitle: {name}\ndate: 2025-01-01T00:00:00Z\n"
            + extra + "---\nPublication fixture.\n"
        )
    cases = [
        ("normal", "2026", [], {"visible"}),
        ("drafts", "2026", ["-D"], {"visible", "draft"}),
        ("future", "2026", ["-F"], {"visible", "future"}),
        ("preview", "2026", ["-D", "-F"], {"visible", "draft", "future", "future-draft"}),
        ("expired", "2026", ["-E"], {"visible", "expired"}),
        ("all", "2026", ["-D", "-F", "-E"], {"visible", "draft", "future", "future-draft", "expired"}),
        ("boundary", "2030", [], {"visible", "future"}),
        ("later", "2031", [], {"visible", "future"}),
    ]
    for name, year, flags, expected in cases:
        destination = root / f"output-{name}"
        subprocess.run(
            ["hugo", "--source", str(root), "--destination", str(destination),
             "--clock", year + "-01-01T00:00:00Z", *flags],
            check=True, capture_output=True, text=True,
        )
        actual = {p.parent.name for p in destination.glob("posts/*/index.html")}
        assert actual == expected, (name, actual, expected)
        print(name, sorted(actual))

断言依次检查默认排除、单独打开各个开关、组合预览,以及时间恰好到达和越过发布边界后的结果。后两次普通构建仍然不包含两篇草稿。未来草稿只有同时使用 -D 和 -F 才会提前出现;-E 则独立包含已过期页面。这证明的是受控条件下 Hugo 的渲染选择,没有覆盖部署调度、CDN 缓存失效,也没有套用本站主动包含未来页面的配置。正式发布时,还需要从本次产物和公共路由分别取得证据,确认生成结果与线上实际一致。

修改发布选项时,应保留普通、未来、草稿、未来草稿和过期五类用例,它们覆盖不同的包含条件。检查本次新生成的文件,不能因为导航里没有链接,就断言被排除的页面肯定不在产物里。

延伸阅读

来源