Zola多语言站点未翻译内容:文件名约定、语言分组与构建自检

发布时间:2026/10/29 14:46:30
Zola多语言站点未翻译内容:文件名约定、语言分组与构建自检 多语言站点最耗神的环节往往不是写内容而是盯住“哪一份还没写”。Zola 多语言站点里的未翻译内容说到底是两件事叠在一起的结果一是文件名约定决定了内容归属哪种语言二是语言分组决定了同一篇内容的不同语言版本能不能被认成“一家人”。这两步里任何一步没对齐构建照样成功页面照样生成可法语站或日语站的访客点进导航看到的要么是 404要么是英文原文要么是空列表。这篇就从最表面的命名约定一路挖到分组逻辑的实现层面把未翻译内容在哪些环节冒头、用什么姿势兜住讲清楚。适合两类人看已经在 Zola 上跑着双语或多语站、正在被“某些页面只译了一半”折磨的维护者以及正准备给站点加第二语言、想一开始就把目录约定设计对的人。哪怕你没读过 Zola 的源码只要跟着走一遍文件名到分组键的推导过程后面那些诡异现象基本都能自己对上号。1. 未翻译内容的三种形态先分清再动手1.1 条目缺失、导航残留、聚合污染很多人说“我的站点有未翻译内容”实际上说的是三件不同的事处理手段完全不一样混在一起谈只会越改越乱。第一种是条目缺失型源语言有blog/rust-notes.zh.md目标语言目录里压根没有rust-notes.en.md。这类最直观但也最容易漏因为构建不会报错只在目标语言的列表页里少一条。判断方法很简单把两种语言的content目录做一次文件名差集缺哪些一目了然。第二种是导航残留型文章本身翻好了但导航栏、页脚、侧边栏里还挂着指向未翻译页面的链接。这类问题的隐蔽之处在于它不在内容层而在模板层。模板里写死了一条/blog/或者遍历section.pages时没做语言过滤链接就指向了另一个语言版本的地址点进去要么跳回默认语言站要么直接 404。第三种是聚合污染型列表页、标签页、RSS、搜索索引这些“聚合产物”把不属于当前语言的内容也卷了进来。典型表现是英文站的标签云里混进了中文文章的标签或者搜索框里搜出一堆你看不懂的文字。根源通常在于get_taxonomy或get_section调用时没传lang参数拿到了默认语言的聚合结果。我在实际项目里遇到的比例大概是条目缺失占六成导航残留占三成聚合污染占一成。但造成线上事故的八成是后两种——因为它们不影响构建只在真实访问时暴露。1.2 为什么 Zola 不做自动回退这是新手最常问的问题某篇文章没有英文版为什么英文站不自动显示中文原文答案在 Zola 的设计取向里。Zola 把每种语言当成一套相对独立的站点来看待而不是同一套内容的多视图。启动时它按default_language和各语言配置分别初始化和渲染每种语言有自己的 section 树、自己的 taxonomy、自己的 sitemap 和 feed。这套设计的好处是干净语言之间不会互相污染一个语言站点出问题不会牵连其他语言代价就是没有隐式的跨语言兜底。get_page(pathblog/rust-notes, langen)如果那篇英文版不存在返回的是构建错误不是中文版。想要降级显示必须自己在模板里先判断、再决定渲染什么。这个判断的依据就是后面要讲的translations变量。提示不要试图用default_language来“兜底”页面内容。它只决定根路径下内容的归属和部分默认行为不会在目标语言缺失时自动搬运内容。明白了这一点后面的所有设计都是在解决同一个问题如何在缺少某语言版本时让构建结果依然体面。2. 文件名约定语言后缀是怎么被认出来的2.1 后缀识别的优先级与边界Zola 判断一份内容属于哪个语言靠的是文件名里最后一个点分段。流程大致是取文件的 stem去掉.md按.切开拿最后一段去和config.toml里声明的语言代码比对命中就是该语言并且这个后缀会从路径里被剥掉没命中整段就当普通文件名的一部分。举个例子配置里声明了zh和en文件名识别出的语言最终 pathabout.md默认语言aboutabout.zh.mdzhaboutabout.en.mdenaboutabout.fr.md默认语言about.frmy.notes.en.mdenmy.notes第四行是重灾区。fr没在配置里声明于是它不但没被当成语言还被塞进了 path生成出来的地址是/about.fr/。这种错误不会报任何警告只会在某天你加语言的时候突然发现整个目录结构全错位了。第五行说明另一件事只有最后一段参与语言判定。my.notes.en.md的多段文件名不会被误判notes那段是安全的。所以文件名里带点是可以的只要最后一段不是语言代码。关于子目录里的_index.md规则一样。content/blog/_index.zh.md会被识别为zh语言的blogsection漏了后缀的content/blog/_index.md就是默认语言的blogsection。这一点很关键因为 section 的语言和后缀如果不匹配下面挂着的页面分组就会出问题。2.2 path 才是分组主键语言只是附属标签这是整篇文章最核心的一句话Zola 分组的依据是 path不是文件名也不是标题更不是某种 ID。about.zh.md和about.en.md剥掉语言后缀之后path 都是about。Zola 在内部用这个 path 作为键把两份内容归到同一组互为翻译。about.zh.md和about-en.md用连字符就完全不是一回事前者的 path 是about后者的 path 是about-en它们会被当成两篇独立的文章永远互不认识。这条规则带来的直接推论是翻译版本必须放在同一个相对目录下且文件名去掉语言后缀后完全一致。你不能把中文版放content/zh/blog/a.md、英文版放content/en/blog/a.md那是另一套目录分语言的方案Zola 原生的文件名约定不是这么工作的。硬要这么放也不是不行但你会失去内建的translations关系语言切换器得自己手搓映射表。比较新的版本里分组完全依赖 path更早的版本曾允许在 front matter 里手动指定一个对齐用的字段。升级老站点时如果发现某些页面的翻译关系突然断了第一件事就是检查文件名约定是否满足“去后缀后 path 全等”。2.3 三种常见命名错误与后果错误一大小写不一致。About.zh.md和about.en.md去掉后缀后一个是大写开头一个是小写path 不相等分组失败。在大小写不敏感的文件系统上你看不出问题部署到大小写敏感的构建环境才炸。错误二多打了连字符。about-zh.md、about-zh-CN.md这类写法语言代码段不是独立的一个点分段识别不出来整个-zh进了 path。错误三section 和 page 的语言不匹配。content/blog/_index.md默认语言下面挂了content/blog/post.en.md。英文文章归属于默认语言的 section英文站的get_section(blog, langen)拿不到这个 section整个博客列表在英文站下就是空的。这种问题排查起来最费劲因为单篇文章的 URL 是好的只有列表页空。注意section 的语言归属决定了它下面所有页面的“可见性入口”。要让某语言的文章出现在该语言的列表页里对应层级的_index必须也有该语言版本。3. 语言分组源码解析一次 HashMap 归并3.1 Library 的嵌套字典结构与插入顺序抛开具体实现细节Zola 内容库的核心结构可以抽象成两层字典。外层键是 path内层是语言到内容的映射。把逻辑抽出来大概是这样// 逻辑示意非源码原文 type Library HashMapString, HashMapString, Content; fn insert(mut self, path: String, lang: String, content: Content) { self.0 .entry(path) .or_default() .insert(lang, content); }每次读到一个 Markdown 文件就解析出(path, lang)这对坐标然后往这个嵌套字典里塞。塞完所有文件整个内容库就成型了。所谓“翻译关系”本质上就是内层字典里 key 的集合——如果有zh和en两个 key说明这对翻译齐全如果只有zh那就是未翻译。这个结构解释了为什么 Zola 对命名如此敏感没有额外的人工对齐步骤只有这一层自动归并。归并不上后面所有环节都拿不到关系。插入顺序在这里也有影响。文件遍历顺序和处理顺序决定了同键冲突时谁覆盖谁这一点下一小节展开。3.2 覆盖与冲突同 lang 同 path 的两份文件如果出现两份能被解析成同一(path, lang)的文件会发生什么比如content/blog/a.md默认语言和content/blog/a.zh.md而zh恰好就是默认语言。这时两份文件的 path 都是blog/alang 也都是 zh内层字典里同一个 key 被写了两次。Zola 的处理策略是后者覆盖前者具体哪个算后者取决于遍历顺序并且通常会在构建日志里提示。这个提示很容易被淹没在一堆输出里所以我的习惯是每次加完新内容都扫一眼构建输出里的 warning看到路径重复一定停下来查。冲突更隐蔽的变体是content/blog/a.md和content/blog/a.en.md而默认语言不是en。这样内层字典里会有default和en两个 key看起来一切正常但如果你本意是让a.md当英文版实际却生成在根路径下、被标记成默认语言英文站的translations里根本找不到它。这种“看起来分组成功但语义错位”的问题只能靠统一命名规范来防。我的做法是除了极少数纯默认语言内容其余全部显式带语言后缀包括默认语言。也就是默认语言的页面也叫a.zh.md不写裸文件名。多打四个字符换来的是“每份文件的语言归属一眼可见”长期看非常划算。3.3 模板侧 translations 的构造过程模板里用的translations是在渲染阶段从内层字典里现场构造出来的。给定一份内容先拿到它的 path去外层字典里取出对应的内层字典然后把该字典里除自己以外的所有条目转换成模板可用的结构通常包含语言代码和该语言下的永久链接。这个过程有两个必须记住的特征。第一translations只包含已经存在的内容不包含“应该有但还没有”的语言。所以它天然是一个“已翻译清单”不是“应翻译清单”。想判断某篇内容缺哪些语言得反过来用遍历config.languages看每个语言代码是否出现在translations的语言集合里差集就是缺的。第二translations里的链接是那条内容自己的永久链接不是当前页面的语言切换链接。很多人在语言切换器里直接遍历它结果发现当前语言没有对应项——因为自己那个条目被排除了。正确做法是先渲染当前语言再遍历translations补充其他语言。模板里大致长这样nav classlang-switch span classcurrent{{ lang }}/span {% for t in page.translations %} a href{{ t.permalink }} hreflang{{ t.lang }}{{ t.lang }}/a {% endfor %} /nav如果某个语言完全没有这篇的翻译这里就不会出现它的入口。对多数站点来说这正是期望行为但也意味着未翻译内容在导航层是彻底不可见的——用户从英文首页点进某篇文章切换器里只有英文想找中文版得自己回首页。这个体验是否可接受取决于你的站点定位下一节讲怎么按需处理。4. 搭骨架从配置到模板的可运行最小样例4.1 config.toml 里的语言声明语言必须在配置里显式声明否则一切文件名约定都是空谈。最小配置大概是这样base_url https://example.com default_language zh compile_sass true build_search_index true [languages.zh] title 某技术博客 generate_feed true taxonomies [{ name tags }] [languages.en] title Some Tech Blog generate_feed true taxonomies [{ name tags }]几个容易忽略的点default_language决定根路径内容的归属也决定一部分默认行为的落点。把它设成站点主要受众使用的语言别因为习惯就设成英文。每个语言的taxonomies必须分别声明。很多人只在顶层写了一行也行但一旦要给某语言加独立分类就得写在这层。漏了的话该语言的标签页面不会生成页面上会出现一堆指向不存在地址的链接。generate_feed是按语言分别生效的每种语言的 feed 只包含该语言内容。如果发现英文 RSS 里混着中文文章基本可以断定是模板里手动拼了全站页面列表绕过了语言过滤。4.2 目录结构与文件命名一个双语博客的实际骨架我会这么组织content/ ├── _index.zh.md ├── _index.en.md ├── about.zh.md ├── about.en.md └── blog/ ├── _index.zh.md ├── _index.en.md ├── rust-notes.zh.md ├── rust-notes.en.md ├── zola-i18n.zh.md └── zola-i18n.en.md注意_index.zh.md里也要有内容哪怕只是标题和一段摘要。它是 section 的语言载体缺了它该语言的列表页get_section拿不到东西。对于暂时不想翻译的页面我的处理是先建一个占位文件front matter 里写draft false但正文只放一句“本文暂无该语言版本”同时在 front matter 里加个自定义字段标记状态。这样做的好处是分组关系先建立起来语言切换器能正常跳转不会出现“点击后 404”代价是搜索引擎可能收录这篇占位页所以要在模板里对这类页面加noindex或者干脆把它们排除在 sitemap 之外。4.3 语言切换器与缺失降级单纯的translations循环只能覆盖“已翻译”的情况。想让缺失时有优雅降级得组合三个信息当前页面 path、全站语言列表、当前页面的翻译集合。{% set all_langs config.languages %} {% set translated page.translations | map(attributelang) | concat(with[lang]) %} nav classlang-switch {% for code, conf in all_langs %} {% if code in translated %} {% if code lang %} span aria-currenttrue{{ conf.title }}/span {% else %} {% for t in page.translations %} {% if t.lang code %} a href{{ t.permalink }}{{ conf.title }}/a {% endif %} {% endfor %} {% endif %} {% else %} a href{{ get_url(path) }} classdisabled title该语言暂无此页{{ conf.title }}/a {% endif %} {% endfor %} /nav这段模板的价值在于把“未翻译”从不可见变成可见且可解释。用户看到另一种语言是灰的鼠标悬停提示“暂无此页”心里有数点进去回到该语言首页比 404 友好得多。get_url(path)的写法依赖于你的语言根路径配置。如果语言采用子路径/en/承载这样写会自动指向该语言首页如果用独立域名需要在配置层处理模板侧改用一个统一入口变量会更省事。4.4 词条、日期与排序的本地化细节界面上的固定文案比如“”“发表于”“标签”属于另一种意义上的“未翻译内容”。它们不在 Markdown 里而在模板里所以做文件差集查不出来。Zola 提供了词条表机制在语言配置下写translations表[languages.en.translations] read_more Read more published_on Published on tag_label Tag模板里用{{ trans(keyread_more) }}取。缺失的词条会回退到默认语言所以如果你只翻译了一半英文站会突然冒出几个中文词——这种“半截翻译”在页面上非常刺眼。我的习惯是加新词条时一次性把所有语言的配置都补上哪怕暂时是机翻加个待审标记也比留空好。日期格式同理。Tera 的日期过滤器需要显式指定格式串不同语言的月份名和日期顺序不一样硬编码一个%Y-%m-%d虽然能跑但中文站显示2024-05-01、英文站也显示2024-05-01读者体验就那样。想本地化得在模板里按lang分支或者用各语言自己的格式串配置项。排序也是个隐藏坑。列表页按date排序时如果某篇文章的英文版日期和中文版日期不同这在分批翻译时很常见两个语言站的文章顺序会不一致。是否有问题取决于站点类型但至少要意识到这件事别等用户反馈“你们英文站的顺序怎么和中文不一样”。5. 分级处理不同站点的不同取舍5.1 全量翻译站的严格模式内容量不大、翻译能力稳定的站点最省心的策略是严格模式任何一篇内容所有语言版本必须齐全缺一不可。落地手段是在构建前跑一个自检脚本比对各语言目录下的文件名集合发现差集就退出非零码让 CI 拦住这次发布。严格模式的代价是发布节奏被翻译进度绑架。缓解办法是把“必须齐全”的范围缩小——只对核心页面首页、关于、产品介绍、定价做严格校验博客文章允许滞后但需要在列表页对未翻译文章做标记处理。我在一个只有十几页的站点上用过严格模式效果很好半年下来没有一次因为漏翻上线。但在日更博客上用就完全不现实那属于跟自己过不去。5.2 部分翻译站的降级模式内容多、翻译跟不上的站点我的建议是降级模式分三层处理。第一层列表页展示所有语言的内容但标注原文语言。中文站的列表里英文文章标题后面挂个EN角标点进去看到的是原文页面顶部有一条横幅“本文暂无中文版本以下为英文原文”。这条横幅的文案本身要尽可能翻译得准确因为它是用户此刻唯一能看懂的引导。第二层导航和侧边栏只展示当前语言已有的条目。这个用translations判断即可缺翻译的条目在导航里隐藏。降级不等于到处留空洞主导航应该干净。第三层搜索索引按语言隔离。每种语言只索引自己语言的内容中文站搜不出英文文章。这条最容易被忽略因为搜索索引是构建期生成的出问题不报错只在用户搜索时表现为“搜出来的东西看不懂”。5.3 共享型页面与“伪翻译”处理有些页面天然不需要翻译比如图片墙、代码片段库、外链收藏。给它们每种语言都建一份一模一样的文件维护成本翻倍还容易不同步。处理思路是只建默认语言版本其他语言用跳转或重定向承接。具体做法是在目标语言的对应路径放一个极简的 Markdown正文只放一段说明加一个链接指回默认语言版本或者在模板层给这类页面单独开一个布局直接渲染默认语言的数据。另一类是伪翻译内容其实一样只有标题和描述做了本地化。这类页面对 SEO 有价值但要小心别让用户在两种语言间来回跳转看到完全相同的内容那体验很怪。我的做法是在页面顶部用词条标注“以下内容为通用资料”说清楚比藏着好。还有一种极端情况某个语言的内容量少到不足以支撑独立站点比如只有两篇文章。这时候与其硬开一个语言站不如把这两篇直接以原文形式放在默认语言站里加个语言标注。开一个只有两页的站点维护成本远大于收益。6. 排查实录构建不报错但线上出问题的那些场景6.1 常见问题速查表下面这张表是我这几年攒下来的按“症状 → 最可能原因 → 验证手法”组织。遇到问题先对表比从头读源码快得多。症状最可能原因验证手法某语言列表页为空对应_index缺该语言版本检查该目录下是否存在_index.lang.md语言切换器里少了某语言该内容缺该语言版本或 path 不一致对比两份文件去后缀后的路径字符串URL 里出现奇怪的语言段语言代码未在配置声明被当成文件名检查[languages]下是否有该代码同一页面生成两份裸文件名与带后缀文件 path 冲突看构建日志里的重复路径警告标签页混入其他语言内容get_taxonomy未传lang在模板里打印实际传入的参数英文 RSS 里有中文feed 模板手动拼了全站页面检查 feed 模板是否遍历section.pages搜索出现其他语言结果索引未按语言隔离检查搜索配置与索引生成路径翻译关系时有时无文件名大小写不统一统一小写部署到敏感环境复现表格只能覆盖高频项真正难缠的是“看起来都对但就是不对”的情况那基本要靠打日志。6.2 构建期自检脚本比事后排查更划算的是事前拦截。我习惯在构建前跑一个小脚本做三件事找缺失、找多余、找路径冲突。#!/usr/bin/env bash set -euo pipefail LANGS(zh en) ROOTcontent # 收集每个语言的文件集合去掉语言后缀后的相对路径 for l in ${LANGS[]}; do find $ROOT -name *.$l.md \ | sed -E s/\.$l\.md$// \ | sort /tmp/zola-$l.txt done # 两两求差集输出缺失情况 for l in ${LANGS[]}; do for m in ${LANGS[]}; do [ $l $m ] continue echo 存在于 $l 但 $m 缺失 comm -23 /tmp/zola-$l.txt /tmp/zola-$m.txt || true done done这段脚本刻意保持简单因为它要能塞进任何 CI 环境。想更严格的话可以在差集非空时直接exit 1把“允许缺失”的白名单写进一个单独的文件脚本里过滤掉。需要说明的是这套逻辑只处理显式带语言后缀的文件。如果你有裸文件名存在默认语言得先把它们也归到默认语言集合里否则会误报一堆“缺失”。6.3 几个我踩过的具体坑坑一加语言时忘了改base_url相关的模板逻辑。新增语言后get_url生成的路径变了但模板里有些地方硬编码了绝对路径语言切换器指向了错误位置。教训是任何涉及 URL 的地方都用get_url或内容自带的永久链接别手拼。坑二把 taxonomy 的_index忘了。标签页的_index也需要语言版本。我一度以为标签是自动生成的不用管结果英文站的标签页打开是空的。补上content/tags/_index.en.md之后才正常。这个文件本身内容可以极简但必须存在。坑三草稿状态不同步。中文版已经发布英文版还挂着draft true。这种情况下英文站的translations里不会包含中文版分组直接断掉语言切换器少一个入口但文章在中文站正常。这种“一边发布一边草稿”的状态最难自查只能靠发布清单核对。坑四分页参数在两种语言下不一致。中文站paginate_by 10英文站忘了写用默认值。结果两边分页数量不同sitemap 里的分页 URL 对不上做 hreflang 标注时全是错位。这类配置最好提到顶层或者用一致的模板变量别在 section 的 front matter 里各写各的。坑五重命名文件后翻译关系断裂。把old-name.zh.md改成new-name.zh.md却没改英文版两份 path 不同了配对失效。这个错误在人眼里是“我明明改了名”在程序眼里就是两个不相干的东西。改名前先确认两边都改或者干脆两篇一起重命名。提示重命名内容文件时务必成对操作。Zola 不会因为你改了中文文件名就自动更新英文版的配对关系它只认最终 path 是否相等。真的要说经验的话未翻译内容这件事的解法不在技术细节里而在流程约束里。把“新增一篇内容必须同时建立所有语言的占位文件”写进自己的操作习惯比任何排查脚本都管用——脚本能告诉你缺了什么但只有流程能让它不缺。我现在的做法是新建内容时先复制一份空壳到每个语言目录正文哪怕只有一句“待补”path 先对齐了后面填内容就只是填空。这个习惯一开始觉得啰嗦用上两个月就回不去了因为你再也不会在半夜收到“你们英文站的某个链接点开是 404”这种消息。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询