DEV Community

Cover image for Fail-Closed i18n: How I Stop Translations From Changing Ticket Times on a Travel Site
Xiaocong
Xiaocong

Posted on Edited on

Fail-Closed i18n: How I Stop Translations From Changing Ticket Times on a Travel Site

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.cn with 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>.
Enter fullscreen mode Exit fullscreen mode

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 ''
Enter fullscreen mode Exit fullscreen mode

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:

  • hreflang alternates on each page list only the languages that page actually exists in, plus x-default → English.
  • Each language gets its own sitemap containing only its published pages, and robots.txt lists 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.