I run a small, free guide that explains how foreign visitors can book attractions in China: which website to use, when tickets go on sale, and what you need at the gate. The content is mostly facts that must not drift:
- "Tickets open at 20:00 Beijing time, 7 days before your visit."
- "On
badaling.cn, click 外籍人购票 (foreigner tickets)." - "Sign in at
pcticket.chnmuseum.cnwith an email code."
The site is in English plus Japanese, Korean, Traditional Chinese, German, Spanish, French and Russian. If a translation turns 20:00 into 21:00, drops a Chinese button label the reader needs to find on screen, or "localises" a domain name, someone misses their tickets. So I built the translation step to fail closed: if anything looks wrong, the page simply isn't published in that language.
Here's how it works. It's about 570 lines of Python and no i18n library.
Step 1: Extract from the built HTML, not the templates
The English site is generated first, as plain static HTML. The i18n step then reads the finished pages and cuts them into translation units. A unit is a run of text plus inline tags (a, strong, em, code, span…) inside a block element. The inline tags become numbered placeholders, so translators never see raw HTML:
HTML: Open <a href="…">bookingticket.dpm.org.cn</a> at <strong>20:00 Beijing time</strong>.
unit: Open <1>bookingticket.dpm.org.cn</1> at <2>20:00 Beijing time</2>.
Atomic tags (<img>, <br>, inline <svg>) become self-closing placeholders like <3/>. The original tags are kept in a side table and put back verbatim after translation, so attributes, classes and URLs can't be damaged.
Units are deduplicated across the whole site, which matters more than I expected: footer text, "Free travel tips on WhatsApp", "Passport at the gate"… The site has about 1,280 HTML files, but far fewer unique strings.
It also extracts a few non-visible strings that matter for search and accessibility: alt, title, aria-label, the meta description and OG tags, and selected keys inside JSON-LD (name, description, text) and in the page's own JSON config blocks.
Step 2: Validate every translation
Each language has a dictionary: { source_unit: translated_unit }. Before a translation is used, it goes through a validator. Simplified:
PH = re.compile(r'<(/?)(\d+)(/?)>')
NUM = re.compile(r'\d+')
def validate(src, tr):
if not tr or not tr.strip():
return 'empty'
# 1. same placeholders (order may change, the set may not)
if sorted(PH.findall(src)) != sorted(PH.findall(tr)):
return 'placeholders differ'
# 2. every Chinese string survives verbatim (button labels, app names)
for zh in re.findall(r'[\u4e00-\u9fff]+', src):
if zh not in tr:
return f'chinese {zh} missing'
# 3. every number in the source appears in the translation
pool = NUM.findall(strip_placeholders(tr))
for n in NUM.findall(strip_placeholders(src)):
if n in pool: pool.remove(n)
else: return f'number {n} missing'
# 4. no invented numbers (with a small allowance, see below)
...
# 5. every domain survives
for dom in re.findall(r'[\w-]+\.(?:com|cn|org|net|gov)\b', src):
if dom not in tr:
return f'domain {dom} missing'
return ''
A few things I learned writing the rules:
Numbers are the most important check. Times, prices, days ahead, age limits, opening hours, daily quotas. The validator treats the source's numbers as a multiset that must reappear in the translation. If a time range loses its end, a price loses a digit, or "7 days" gets rewritten as "a week" (fine for a human, but I'd rather keep the digit), the unit is rejected.
Allow some extra numbers, but not many. Translations legitimately add numbers. Japanese writes dates as 10月8日, and French writes "XIXe siècle" for "19th century". The rule I settled on: extra numbers are allowed only from 1 to 12 (months, hours), or if the English source contains the matching word ("two", "noon", "week", "daily"). Roman-numeral centuries in Spanish, French and Russian are counted as their number. Anything else is rejected.
Chinese text must survive exactly. The guide constantly tells readers to look for a Chinese label on screen: 外籍人购票, 预约购票, 个人预约. A translator (human or machine) will "helpfully" translate them, and then the reader can't match the button. So any run of CJK ideographs in the English source must appear unchanged in the translation. (For Traditional Chinese the label stays in Simplified, because that's what the booking site shows.)
Domains are facts too. en.mutianyugreatwall.com must not become a link to something else, or get a space inserted by a well-meaning tool.
Step 3: Publish per page, only if everything passes
For each language, a page is rendered by replacing units right to left (so character offsets stay valid), then translating attributes and JSON-LD, then rewriting internal links to the localised paths when the target page exists in that language.
The key rule: a page is published in a language only if every unit on it has a valid translation. One missing or invalid sentence and that page stays English-only for that language. The build writes a report listing which pages were held back and why, which doubles as a to-do list.
Because publishing is per page, the SEO bits follow automatically:
-
hreflangalternates on each page list only the languages that page actually exists in, plusx-default→ English. - Each language gets its own sitemap containing only its published pages, and
robots.txtlists all of them. - Internal links never point into a language version that doesn't exist. They fall back to the English URL.
Right now each of the seven languages has about 150 published pages. The long-form English guides aren't translated yet, so they stay English-only, and the hreflang tags don't pretend otherwise.
Step 4: A visible note, and English wins
Every translated page shows a short note in that language: "This page is translated from the English original. If they differ, the English version prevails," with a link to the English page. For a site where the facts are ticket times, I think that's the honest default.
What I'd tell someone doing the same
- Translate the output, not the templates, if your content is mostly prose. It removes a whole class of "forgot to wrap this string" bugs.
- Write validators for the facts in your domain. For me that's numbers, Chinese labels and domains. For an e-commerce site it might be SKUs and prices, and for docs it might be code identifiers.
- Fail closed, per page. An untranslated page is a small loss. A wrong translated fact is a real one.
If you've done something similar, especially number handling for languages with different date formats, I'd love to compare notes in the comments.
The site this runs on is Simple China Trip, a free booking guide for foreign visitors to China.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.