news 2026/10/28 5:24:12

Zola多语言站点:未翻译内容、语言分组与切换器兜底

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Zola多语言站点:未翻译内容、语言分组与切换器兜底

多语言站点做多了,最烦的从来不是翻译本身,而是“有的页面有译文、有的没有”这种半成品状态。我用 Zola 给一个内部文档站做中英日三语的时候,就栽在这上面:英文页的语言切换器上老老实实列着三个语种,切到日文页只剩两个,剩下那个不报错、不提示,就是凭空消失。构建日志干净得像什么都没发生,可页面上就是少了一个入口。这类问题在 Zola 的多语言站点里非常典型,它的根源不在于模板写错,而在于未翻译内容在构建期根本没有进入“语言分组”——你的模板再怎么遍历,也遍历不到一个不存在的条目。Zola 处理多语言的方式很朴素:文件名后缀决定语言,源文件路径决定身份,构建时按路径把不同语言的版本归到一组,再把这组数据回填给每个页面。搞懂这条链路,未翻译内容带来的断链、空切换器、伪翻译、构建报错,全都能一次性按住。这篇适合两类人看:正在用 Zola 搭多语言站、已经被语言切换器坑过一次的;以及想搞清楚静态站点生成器“翻译分组”到底怎么落地的。下面从文件名约定一路讲到语言分组的源码链路,中间穿插能直接抄的模板和脚本。

1. Zola 认语言的方式:后缀即语言,路径即身份

1.1 文件名后缀是唯一入口,别指望目录名

Zola 判断一个内容文件属于哪种语言,只看一件事:文件名的倒数第二段是不是配置里声明过的语言代码。about.md是默认语言,about.fr.md是法语,about.zh-CN.md是某个中文变体。除此之外没有第二个入口——目录名带语言后缀(比如content/fr/about.md)不会被认为是法语内容,content/blog.fr/_index.md也不会。这一点和某些生成器用目录分语言的做法完全不同,从别的工具迁过来的人最容易在这里翻车:把内容按语言拆进子目录,构建出来的站点看着能跑,但所有页面都被当成默认语言,URL 全挤在一起互相覆盖,最后只剩一份。

提示:语言代码里的连字符、大小写要跟config.toml里声明的键完全一致。声明的是zh-CN,文件写成about.zh-cn.md就不会被识别,它会老老实实变成一个路径里带zh-cn的普通页面。

我见过更隐蔽的一种:有人为了让 URL 好看,把语言代码写成了zh_Hans这种带下划线的形式,然后在配置里也照抄。理论上能跑通,因为匹配是纯字符串比较,但后面接翻译平台、接 hreflang 标签的时候会因为不符合常规写法而反复出问题。语言代码就用常规的en、fr、ja、zh-CN,别自创。

1.2 path、slug、语言后缀的解析顺序

这三个东西经常被混为一谈,但在 Zola 里它们的职责分得很清楚。语言后缀只负责“这份内容属于哪个语言”,它会在解析的最早阶段被剥掉;剥掉之后剩下的相对路径才是这份内容在站点结构里的身份;至于最终 URL,是再往后由slug(改最后一段)和 front matter 里的path(改整段)决定的。

顺序大致是这样:先拿文件名拆语言,得到“去掉语言后缀的源路径”;再根据源路径确定它属于哪个 section、是不是_index.md;最后应用slug/path覆盖,拼出带语言前缀的最终地址。这个顺序解释了为什么about.fr.md配path = "a-propos"会得到/fr/a-propos/而不是/a-propos/——语言前缀是在 URL 拼接阶段加的,跟path覆盖不冲突。

配置源文件语言最终 URL
默认content/about.mden/about/
默认content/about.fr.mdfr/fr/about/
slug = "a-propos"content/about.fr.mdfr/fr/a-propos/
path = "legal/terms"content/about.fr.mdfr/fr/legal/terms/
目录带语言content/fr/about.mden/fr/about/(语言识别失败)

最后一行就是前面说的坑:它不报错,只是语言判断落到了默认值,于是这份“法语内容”被当成英文内容发布,还占了/fr/about/这个路径。等到真正的法语版按约定命名进来,两者就在同一个 URL 上打架,构建时会出现覆盖或重复的怪现象。

1.3 为什么没有 translationKey 这种东西

如果你之前用过别的静态站点工具,可能会习惯性地在 front matter 里找translationKey——用一个自定义的稳定 ID 把不同语言的同名内容绑在一起,文件名怎么变都不影响分组。Zola 没有这个东西,它的分组完全依赖“去掉语言后缀之后的源文件路径是否相同”。about.md和about.fr.md因为剥离后都是about.md,所以被归为同一组;about.md和about-us.fr.md剥离后一个是about.md、一个是about-us.md,哪怕内容讲的是同一件事,也永远进不了一组。

这个设计有它的道理:省掉一个必须手动维护的字段,少一个出错的地方,而且分组逻辑可以完全在构建期推导出来,不需要用户操心。代价是约束变硬了——译文文件的文件名必须和原文严格对齐,只多一个语言后缀。你可以改slug和path让 URL 各语言不同,但不能改文件名本身。

我在一个项目里还真动过改文件名的念头:中文标题很短,英文标题很长,当时的想法是英文文件名用更语义化的getting-started-with-config.md,中文用config.md。试了一次就放弃了,因为一旦文件名不对齐,语言切换器上那个页面就永远只有它自己一个语种。后来改成全部用同一套文件名、只在 front matter 里写各自的title和slug,问题消失,而且后面接翻译平台的时候,文件名作为唯一键反而更好对齐。所以这条约束别对抗,顺着它设计目录就好。

2. 缺一个 .fr.md 之后,构建期到底发生了什么

2.1 一个最小可复现的目录结构

光说概念不直观,我把当时那个最小复现结构摆出来。配置里声明了三种语言,内容目录里故意只给部分文件配了译文:

# config.toml default_language = "en" taxonomies = [{ name = "tags" }] [languages.en] title = "Docs" [languages.fr] title = "Documentation" [languages.ja] title = "ドキュメント"
content/ ├── _index.md ├── _index.fr.md ├── _index.ja.md ├── guide/ │ ├── _index.md │ ├── _index.fr.md │ ├── install.md │ ├── install.fr.md │ ├── install.ja.md │ ├── deploy.md │ └── deploy.fr.md └── changelog.md

这份结构里有三类不同的“未翻译”状态,症状完全不一样,值得分开看:guide/deploy.md缺日语(有韵脚但缺一个)、changelog.md只有英文(完全没翻译)、guide/_index.ja.md缺日语(section 首页缺失)。这三种在处理方式上不能一视同仁,后面会分别讲。

2.2 translations 里少了的那一条

Zola 较新的版本会在模板里给page和section提供一个translations数组,里面装的是同一组里其他语言版本的精简信息,大致包含语言代码、该语言下的永久链接、以及标题之类的元信息(具体字段各版本略有出入,以你锁定的版本为准)。关键点在于:这个数组只包含真实存在的版本。guide/deploy.md有英文和法语,那英文页的translations里是英、法两条,法语页的也是英、法两条;日语版本不存在,所以日语那条根本不在数组里。

于是模板里一句最朴素的遍历:

{% for t in page.translations %} <a href="{{ t.permalink }}" hreflang="{{ t.lang }}">{{ t.lang }}</a> {% endfor %}

在deploy页面上渲染出来,法语的页面上是两个链接,切到日语……没有日语页面,因为/ja/guide/deploy/压根没生成。真正的症状在install页:它有英、法、日三份,translations数组是三条,切换器上三个语种齐活;用户从install页切到英语的deploy页,切换器突然只剩两个。用户的感受是“这个站的导航在乱跳”,而不是“这个页面没翻译”。

2.3 四种症状与它们的根因对照表

把踩过的都归拢一下,未翻译内容在 Zola 里通常表现为下面四种形态,根因各不相同:

症状触发条件根因处理方向
切换器条目数随页面变化某些页面缺某语言版本translations只含已存在版本模板层补占位或统一策略
访问某语言下某页面 404整个文件缺失该语言下从未生成该路由占位页或重定向到默认语言
构建期 Tera 报错模板里get_page(lang=...)取不到取不到时返回错误而非空值加default兜底或用translations
搜索结果出现“伪翻译”复制原文另存为.fr.md分组认为该语言已存在显式标记未翻译状态

第三行值得多说一句。get_page在找不到目标时不是返回null让你判断,而是直接让模板渲染失败、构建中断。所以“我加个 if 判断一下就行”的思路在 Zola 里行不通,必须用 Tera 的default过滤器或者干脆绕开get_page改用translations。这个行为差异是很多人第一次写多语言模板时最不适应的地方。

第四行是另一种隐蔽问题:为了“看起来完整”,有人把英文正文原封不动复制一份改成.fr.md。从语言分组的角度看,这个页面“已翻译”,切换器正常显示法语入口,但它指向的是英文内容。用户在法语站里点进法语链接看到英文,体验比 404 还差;从搜索的角度看,两份内容高度重复,等于自己跟自己竞争。这个坑后面单独讲。

3. 语言分组的源码链路:从 get_path_and_lang 到回填 translations

3.1 第一段:拆后缀 + 白名单校验

Zola 的源码结构不复杂,语言相关的逻辑主要落在内容模型(components/library下的内容模块)和站点装配(components/site)两处。第一段逻辑是“从文件名里认出语言”,核心函数大意叫get_path_and_lang,输入是文件绝对路径、内容根目录、以及配置里声明的语言列表,输出是一个二元组:去掉语言后缀的路径和识别出的语言代码。

它的判断过程可以概括成三步,我用自己的话重新表述一遍(下面是示意代码,不是逐行照抄源码):

// 示意:语言识别的核心分支 fn get_path_and_lang(path: &Path, languages: &[String], default: &str) -> (PathBuf, String) { let stem = path.file_stem().unwrap(); // "about.fr" if let Some((rest, candidate)) = stem.rsplit_once('.') { // ("about", "fr") if languages.contains(&candidate.to_string()) { // 白名单校验 let new_name = format!("{}.{}", rest, path.extension().unwrap().to_str().unwrap()); return (path.with_file_name(new_name), candidate.to_string()); } } (path.to_path_buf(), default.to_string()) // 认不出 → 默认语言 }

三个细节值得记:第一,拆分用的是rsplit_once,也就是从右边切最后一个点,所以about.v2.fr.md会被正确识别成fr,剥离后是about.v2.md——文件名里带点不会把语言识别搞坏。第二,识别结果必须过一遍白名单,只有配置里声明过的语言代码才算数,否则guide.setup.md这种文件会被误判成语言setup。第三,认不出来的时候静默回落到默认语言,不抛错,这就是前面/fr/about/那个坑的源码级原因——它没有错,只是按默认语言处理了。

这个函数是整条链路的入口,也是唯一会“决定语言”的地方。后面的所有逻辑都建立在它给出的结果之上,所以命名约定一旦错位,后面再怎么写模板都救不回来。

3.2 第二段:以源文件路径为键做分组

识别完语言,接下来是把所有内容按“身份”归拢。Zola 的做法是遍历所有页面和 section,用去掉语言后缀之后的源文件路径作为分组键,往一个哈希表里塞:

// 示意:分组阶段 let mut groups: HashMap<PathBuf, Vec<TranslatedContent>> = HashMap::new(); for page in &library.pages { let key = page.file.path.clone(); // 已剥离语言后缀的源路径 groups.entry(key).or_default().push(page.as_translated()); } for section in &library.sections { let key = section.file.path.clone(); groups.entry(key).or_default().push(section.as_translated()); }

这里有两个容易误解的点。其一,分组键是内容文件的源路径,不是 front matter 里的path字段,也不是最终的 URL。path字段只影响输出地址,你要是拿它当分组依据,那各语言 URL 天然不同(带着语言前缀呢),分组会全部碎掉。源码里用的是文件位置,这也解释了为什么文件名必须严格对齐。

其二,section 和 page 走的是同一套分组逻辑。content/guide/_index.md和content/guide/_index.fr.md剥离后都是guide/_index.md,所以它们能正确配对;而content/guide/_index.md和content/guide/install.fr.md剥离后是两个不同的键,一个 section 一个 page,永远不会进同一组。这一点在排查“为什么 section 的切换器里少了语言”时特别有用——多半是_index.ja.md没建。

3.3 第三段:回填到 page / section 的 translations

分组完成后还要回填,把每组的内容列表写回组内每个成员的translations字段,模板层才拿得到数据。这个回填动作发生在站点装配阶段(大致是Site初始化之后、模板渲染之前的某个环节),遍历一遍所有页面和 section,查表赋值。

回填的一个隐含语义是对称性:只要两个文件被分到同一组,它们的translations数组内容完全一致,都包含彼此。所以不存在“英文页知道有法语版、法语页却不知道有英文版”这种单边情形。如果你遇到切换器在某个页面上莫名少项,别去怀疑对称性,直接去查那个语言的文件到底存不存在。

还有一个语义是组内包含自己。多数实现里translations会包含当前页面自身,这样模板遍历时不需要额外判断“当前语言要不要跳过”,而是靠比较语言代码来决定高亮状态。这一点不同版本可能有差异,稳妥的写法是遍历时用语言代码跟当前页面语言比对,而不是靠数组索引,后面给的模板就是这么写的。

3.4 从源码能反推出的四条硬约束

把上面三段串起来,能反推出四条不能商量的约束,这些比翻文档管用:

  1. 译文文件名 = 原文文件名 + 语言后缀,一个字都不能差。包括大小写、连字符、中间的点。
  2. 语言代码必须在配置里有声明。没声明的一律当默认语言处理,且不报错。
  3. 分组只看文件位置,跟 URL、slug、title 无关。改动这三样不会破坏分组,改动文件名会。
  4. 缺失即缺席。没有占位、没有空对象、没有自动回退到默认语言。模板里遍历不到就是真没有。

第四条是整篇文章所有问题的总根源。Zola 在这件事上的态度很明确:未翻译就是未翻译,它不会替你编造一个“该语言下存在此页面”的假象,因为那会导致 hreflang、canonical、sitemap 一系列地方出现指向错误语言的地址。理解这个态度,后面所有的兜底方案设计起来就顺了——我们要做的不是伪造页面,而是在界面上诚实表达“这里暂时没有”。

4. 模板层兜底:让未翻译页面不断链、不误导

4.1 语言切换器的三种策略与取舍

面对“有的页面缺译文”,有三种主流处理方式,各有代价,选哪个取决于站点性质:

策略一:只显示存在的。就是最朴素的遍历,没翻译就不显示。实现最简单,也不会有任何断链,但用户会看到切换器条目数量在页面之间变化。对文档站这种“用户很在意我是不是切错了语言”的场景,这个体验偏差;对营销页那种页面数量少、语种数量多的场景,反而还行。

策略二:显示全部语言,缺失的置灰不可点。好处是切换器的位置和条目数恒定,用户的心理预期稳定,也知道“这个页面确实没有日语版,不是我操作错了”。代价是需要拿到完整的语言列表,并且在模板里做差集运算。

策略三:缺失的跳到一个明确的“暂未翻译”页。这个页面告诉用户“你请求的内容目前只有英文版”,并提供返回对应英文页面的链接。这是三种里体验最好的,成本也最高——需要多一套模板,还要处理好 canonical。

我个人在文档站上固定用策略二加策略三的组合:切换器里缺失项置灰,鼠标悬停给提示,点击跳到说明页。这样用户从任何入口进来都不会撞 404,也不会被静默地导到一个不存在的语言。

4.2 get_page 的 lang 默认值是个坑

写兜底逻辑时,很多人第一反应是用get_page手动去默认语言里捞内容,因为translations只给已存在的版本,而他们想要“拿不到就用英文顶上”。这个思路本身没错,但get_page的行为得先搞清楚。

get_page(path, lang, metadata_only)这个函数,lang是可选参数。问题就出在这个可选上:不传lang时,它用的是站点的默认语言,不是当前渲染上下文所在的语言。也就是说,在法语页面里写get_page(path="guide/deploy.md"),拿到的是英文版本,而不是法语版本。这个默认值设计得反直觉但很合理——函数是全局的,它没有“当前页面”这个概念。你要拿当前语言,必须显式传lang=page.lang。

再叠加上前面提过的行为:取不到时直接让模板渲染失败,而不是返回空。所以正确的兜底写法必须用default过滤器包一层:

{% set fallback = get_page(path="guide/deploy.md", lang="ja") | default(value=false) %} {% if fallback %}<a href="{{ fallback.permalink }}">日本語版を読む</a>{% endif %}

default(value=false)把失败结果兜成布尔假值。这个写法在所有需要跨语言取页面的地方都适用,包括手写 hreflang 标签、生成推荐阅读、做跨语言的重定向映射表。

4.3 可直接抄的切换器宏与占位页模板

把策略二落地成一个可复用的 Tera 宏。用宏而不是 include,是因为宏可以传参,切换器既要在 page 上用也要在 section 上用,参数化更省事:

{# templates/macros/i18n.html #} {% macro lang_switcher(translations, current_lang) %} <nav class="lang-switcher" aria-label="Language"> <ul> {% for code, conf in config.languages %} {% set hit = translations | filter(attribute="lang", value=code) | first %} {% if hit %} <li> <a href="{{ hit.permalink }}" hreflang="{{ code }}" {% if code == current_lang %}aria-current="true" class="is-active"{% endif %}> {{ conf.name | default(value=code) }} </a> </li> {% else %} <li> <span class="is-missing" title="This page has no {{ code }} version yet" >{% import "macros/i18n.html" as i18n %} {% if page %} {{ i18n::lang_switcher(translations=page.translations, current_lang=page.lang) }} {% elif section %} {{ i18n::lang_switcher(translations=section.translations, current_lang=section.lang) }} {% endif %}

有三个细节是我改了几版才定下来的。conf.name用了default(value=code),因为不是每个人都会给语言配置写name,缺了会渲染成空字符串,加个兜底至少能看到代码。用filter(attribute="lang", ...)而不是嵌套for + if拼字符串,是因为前者没有副作用,后者在 Tera 里累加变量挺别扭。aria-current用语言代码比对而不是判断“是不是第一条”,这样即使translations里包含当前页自身也照样正确。

占位页的做法是给缺译文的路径建一个真文件,标题写明语言状态,正文用extra标记,模板统一渲染:

# content/guide/deploy.ja.md +++ title = "未翻訳のお知らせ" template = "untranslated.html" [extra] source_lang = "en" source_path = "guide/deploy.md" original_title = "Deploying in production" +++

对应的untranslated.html里用get_page(path=page.extra.source_path, lang=page.extra.source_lang)拿到原文链接。这么做的好处是路由是真实存在的,不会 404,hreflang 也是对的;坏处是每个未翻译页面都得建一个文件——所以实际项目中我都是写脚本批量生成,而不是手写。

5. 把“未翻译清单”做进流水线

5.1 扫描脚本:按源文件路径归一化

模板兜底只能让用户不难受,团队自己得知道到底缺了多少。这件事必须自动化,靠人工维护清单一定会过期。思路很简单:把内容目录里所有文件按“剥离语言后缀”归一化,统计每个归一化路径下有哪些语言,再跟完整的语言列表做差集。

# scripts/i18n_gap.py import sys, tomllib from pathlib import Path from collections import defaultdict root = Path(__file__).resolve().parent.parent cfg = tomllib.loads((root / "config.toml").read_text(encoding="utf-8")) LANGS = list(cfg.get("languages", {}).keys()) or ["en"] DEFAULT = cfg.get("default_language", LANGS[0]) CONTENT = root / "content" def normalize(p: Path) -> tuple[Path, str]: parts = p.name.split(".") if len(parts) >= 2 and parts[-2] in LANGS: lang = parts[-2] base = ".".join(parts[:-2] + parts[-1:]) # 去掉语言那一段 return p.with_name(base), lang return p, DEFAULT groups = defaultdict(dict) # 归一化路径 -> {lang: 真实文件} for f in CONTENT.rglob("*.md"): base, lang = normalize(f) if lang in groups[base]: print(f"[重复] {base} 出现两个 {lang} 文件: {groups[base][lang]}, {f}") groups[base][lang] = f

normalize的关键是parts[:-2] + parts[-1:]这个拼接,它把中间的语言段丢掉、保留扩展名,foo.bar.fr.md会变成foo.bar.md,跟源码里rsplit_once的效果对齐。这里的归一化规则必须和 Zola 的规则保持一致,否则脚本算出来的缺口跟实际构建结果对不上,比不检测还糟。我只把parts[-2]作为候选语言、并且做了白名单校验,就是为了跟源码那句“认不出就用默认语言”的行为对齐。

顺手加了重复检测:同一归一化路径下出现两个同语言文件,基本就是重命名没改干净,构建时会出现两个页面抢同一个 URL 的怪现象,早点报出来省事。

5.2 报告输出与 CI 阈值

扫完之后输出两种格式,一种给人看,一种给机器判:

missing = [] total = len(groups) for base, have in sorted(groups.items()): lack = [l for l in LANGS if l not in have] if lack: missing.append((base, lack)) print(f"内容组总数: {total}, 不完整组: {len(missing)}") for base, lack in missing: print(f" {base.relative_to(CONTENT)} 缺: {','.join(lack)}") rate = len(missing) / total if total else 0 limit = float(sys.argv[sys.argv.index("--max-missing-rate") + 1]) \ if "--max-missing-rate" in sys.argv else 1.0 if rate > limit: print(f"缺失率 {rate:.1%} 超过阈值 {limit:.0%}") sys.exit(1)

阈值怎么定是个经验活。我一般这么设:核心导航路径(首页、栏目首页、安装、快速开始)要求 100% 完整,阈值设 0;全站缺失率允许到 20%~30%,超过就卡构建。之所以不全卡死,是因为内容站总有些边角材料(历史公告、废弃说明)不值得翻,全站卡死会让人想办法绕开检测,那就彻底失效了。按住关键路径、放宽长尾,是可持续的做法。

还有一点:这个脚本我放在 pre-commit 和 CI 里都跑,但两边行为不同。pre-commit 只打印不拦截,让人能提交中间状态;CI 上才按阈值硬拦。要是一提交就卡,本地开发会被搞得很烦,最后大家统一加--no-verify,等于白装。

5.3 与 sitemap、feed 的一致性核对

清单脚本之外,还有一个容易被忽略的验证点:构建产物里的 sitemap 和 feed 是不是也按语言正确切分了。理想状态下,sitemap 里每个语言分区只应该出现该语言下真实存在的 URL,不应该出现“默认语言的 URL 出现在法语分区”这种情况。feed 也一样,每种语言的订阅源里只放该语言的内容。

我遇到过一回怪事:法语页面列表里混进了几个英文 URL。查下来是模板里手写了一个“最新文章”区块,用get_section(path="blog/_index.md")直接拿了默认语言的 section,然后在所有语言的页面上渲染,于是英文文章链接出现在了法语页面上,还被爬虫抓进了 sitemap 分区。根因还是 4.2 讲的那个默认值问题——不传lang就是默认语言。

所以一致性核对就两条命令:构建后 grep 一遍各语言产物里有没有跨语言前缀的链接,再看 sitemap 分区的条目数跟内容清单对不对得上。这两条写进 CI 成本极低,能挡住 90% 手写模板导致的语言串台。

# 粗筛:某语言产物里出现的其他语言前缀链接 grep -ro 'href="/ja/' public/fr/ | head -20 grep -ro 'href="/fr/' public/ja/ | head -20

6. 命名约定与目录结构的长期维护

6.1 目录镜像 vs 平铺后缀

因为 Zola 的分组键是源文件路径,目录同名文件并列是天然成立的,content/guide/install.md旁边放一个content/guide/install.fr.md就是正确姿势。真正的分歧在于:什么时候该拆目录。

一条经验是按内容领域拆目录,不按语言拆目录。content/guide/、content/api/、content/changelog/这种拆法语义清晰,每个目录下放全套语言后缀文件,分组永远不会错。而content/en/、content/fr/这种按语言拆目录的做法在 Zola 里根本行不通——它会把这些目录当成普通 URL 段,所有内容都被认成默认语言,前面已经讲过。

结构方式分组是否正确新增语言成本适用场景
同目录 + 语言后缀正确每页加一个文件绝大多数项目
按语言分目录完全失效——不适用
按语言分内容仓库需构建期合并新建仓库多团队分仓协作

第三行是个变体:有些团队把各语言内容放在不同分支或不同仓库,构建前用一个脚本把文件合并到同一个content/下再构建。这条路能走通,前提是合并脚本得按 5.1 的归一化规则落盘,否则又回到文件名对不齐的老问题。我参与过的一个项目就这么干,好处是各语言团队不用抢同一个仓库的分支,坏处是合并这一步失败时,构建出来的站点会静默地少一整个语种——所以合并脚本跑完必须有一句“各语言文件数是否符合预期”的断言。

6.2 伪翻译的隐性成本

最后专门说说把原文复制成.fr.md这件事,因为它太常见了。从语言分组的角度看它无害——分组认为该语言版本存在,切换器正常,路由正常,一切绿灯;从用户视角看它是最糟的,因为你把一个英文页面伪装成了法语页面。用户以为自己切到了法语版,读了两段发现是英文,这个信任损失比“暂时没有法语版”大得多。

从搜索的角度算一笔账:两份内容高度重复的页面,在同一个站点下互相竞争同一批查询,权重被摊薄;如果做了 hreflang 互指,等于明确告诉搜索引擎“这两个是同一内容的两个语言版本”,而实际内容语言相同,这属于错误标注。清理起来也麻烦,因为爬虫已经收录了。

我的处理方式是给“未翻译”建一个显式状态字段,而不是靠文件存不存在来隐式表达:

+++ title = "Guide de déploiement" [extra] translation_state = "pending" # pending | machine | reviewed +++

模板里读到pending就在顶部挂一条横幅,说明这是自动生成的占位内容,正式版本还在翻译中,并给出原文链接。这样既保证了切换器、路由、sitemap 的完整性,又不会误导读者。machine用于机器翻译初稿,reviewed是人工校对过的状态,配合 5.1 的脚本就能出一份“各语言翻译进度”的报表,交给内容团队排期。

注意:extra里放状态字段是内容层面的约定,不参与 Zola 的分组逻辑。分组永远只看文件名和路径,别指望用extra里的字段去改变分组结果。

6.3 踩坑记录与几条硬规矩

收个尾,把我在这条链路上踩过的、值得记下来的几条规矩列一下,都是付出过构建时间或者用户投诉换来的:

语言代码在配置、文件名、模板三处必须逐字符一致。大小写不一致是最常见的隐形 bug,zh-CN和zh-cn在字符串比较里是两个东西,识别失败后静默回落默认语言,构建成功、日志干净、页面全错。

不要在模板里硬编码语言列表。我早期在切换器里手写了三个语言的链接,后来加了第四种语言,改了配置忘了改模板,新语言在所有页面的切换器上都不可见,整整两周没人发现。改成遍历config.languages之后这类问题绝迹。

跨语言取页面永远显式传lang。这是那个默认值坑的直接对策。项目里只要出现get_page、get_section、get_taxonomy这类函数而没传lang,我基本默认它是 bug,除非确认就是要拿默认语言的内容。

占位页要能被脚本生成,不要手写。手写的占位页一定会跟内容更新脱节:原文改了标题、加了章节,占位页上还挂着旧标题。批量生成的占位页每次构建前刷新,状态跟内容始终对齐。

构建期校验比模板兜底更重要。模板兜底解决的是用户看到什么,构建期校验解决的是团队知道什么。只做前者,缺失会一直静默累积到某个页面变成孤岛;只做后者,用户还是会撞上置灰的切换器。两个都做,成本其实不高,加起来不到两百行代码。

真要说体会,Zola 在多语言这块的设计取向很清楚:它只负责“如实反映内容的存在状态”,不负责“帮你把不完整的内容包装成完整的样子”。未翻译内容之所以让人头疼,多半是因为我们潜意识里希望工具替我们圆场,而它偏不。顺着这个取向走,把不完整当成一种需要显式表达的正常状态——该置灰的置灰、该标注的标注、该报警的报警——整件事反而清爽了:构建产物永远诚实,用户永远知道自己在看什么,团队永远知道还欠多少活。这套思路搬到别的静态站点工具上也一样成立,只是字段名和分组规则要重新对一遍。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 5:24:04

Hyperframes:高性能嵌入帧管理方案,解决多iframe性能瓶颈

1. 从“hyperframes”这个词说起&#xff1a;它到底是什么&#xff0c;为什么值得聊第一次看到“hyperframes”这个词&#xff0c;很多人会愣一下。它不像“hypervisor”那样在技术圈耳熟能详&#xff0c;也不像“iframe”那样是前端开发天天打交道的标签。但如果你最近在关注前…

作者头像 李华
网站建设 2026/10/28 5:22:46

AI在智能运维中的核心应用与落地实践

1. 运维行业的AI革命&#xff1a;从自动化到智能化的跃迁运维领域正在经历一场由AI驱动的深刻变革。记得2016年第一次接触基于规则引擎的自动化运维工具时&#xff0c;我们已经觉得是巨大进步。但如今&#xff0c;AI技术带来的认知能力让运维工作发生了质的变化。最直观的感受是…

作者头像 李华
网站建设 2026/10/28 5:22:43

GIS与AGIS关系辨析:公司间交易引擎配置与Fusion迁移

1. 先把概念捋直&#xff1a;GIS 和 AGIS 到底是不是两套东西去年帮一个集团做自研 ERP 平台的公司间交易模块评审&#xff0c;方案文档第一页就写着“本期需对接 GIS 与 AGIS 两套公司间交易能力&#xff0c;分别覆盖存量与增量场景”。评审进行到一半&#xff0c;被一位做财务…

作者头像 李华
网站建设 2026/10/28 5:22:41

WorkBuddy行业应用指南:六大跨行业实战案例与落地方法

1. WorkBuddy 到底是个什么东西&#xff1a;为什么一线从业者都在聊它先说句实在话&#xff1a;过去两个月里&#xff0c;我被问得最多的不是"WorkBuddy 怎么安装"&#xff0c;而是"WorkBuddy 到底能帮我干什么"。这个问题听起来很基础&#xff0c;但恰恰说…

作者头像 李华
网站建设 2026/10/28 5:22:22

技能学习的本质:自动化、刻意练习与技能组合的复利效应

1. 技能到底是什么&#xff1a;先搞清楚你在学的东西我见过太多人&#xff0c;年初列了一长串想学的东西&#xff1a;Python、剪辑、英语、写作、健身、理财&#xff0c;年末盘点的时候&#xff0c;没有一个拿得出手。问题往往不出在“不够努力”&#xff0c;而在于对“技能”这…

作者头像 李华
网站建设 2026/10/28 5:20:25

虚拟置景技术:商业摄影效率革命与实战指南

1. 项目背景&#xff1a;商业摄影的效率革命去年帮朋友拍淘宝服装图时&#xff0c;亲眼见证了一场"影棚灾难"——为了拍一组基础白底图&#xff0c;团队折腾了整整两天&#xff1a;第一天搭景布光到凌晨&#xff0c;第二天发现模特档期冲突重拍。这种低效高成本的模式…

作者头像 李华