<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Xiaocong</title>
    <description>The latest articles on DEV Community by Xiaocong (@caistrong).</description>
    <link>https://dev.to/caistrong</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4170604%2F2b72806b-7df2-46d2-9b63-0a1f57776d03.jpg</url>
      <title>DEV Community: Xiaocong</title>
      <link>https://dev.to/caistrong</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/caistrong"/>
    <language>en</language>
    <item>
      <title>Fail-Closed i18n: How I Stop Translations From Changing Ticket Times on a Travel Site</title>
      <dc:creator>Xiaocong</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:06:06 +0000</pubDate>
      <link>https://dev.to/caistrong/fail-closed-i18n-how-i-stop-translations-from-changing-ticket-times-on-a-travel-site-aca</link>
      <guid>https://dev.to/caistrong/fail-closed-i18n-how-i-stop-translations-from-changing-ticket-times-on-a-travel-site-aca</guid>
      <description>&lt;p&gt;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 &lt;strong&gt;facts that must not drift&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Tickets open at &lt;strong&gt;20:00&lt;/strong&gt; Beijing time, &lt;strong&gt;7&lt;/strong&gt; days before your visit."&lt;/li&gt;
&lt;li&gt;"On &lt;code&gt;badaling.cn&lt;/code&gt;, click &lt;strong&gt;外籍人购票&lt;/strong&gt; (foreigner tickets)."&lt;/li&gt;
&lt;li&gt;"Sign in at &lt;code&gt;pcticket.chnmuseum.cn&lt;/code&gt; with an email code."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;strong&gt;fail closed&lt;/strong&gt;: if anything looks wrong, the page simply isn't published in that language.&lt;/p&gt;

&lt;p&gt;Here's how it works. It's about 570 lines of Python and no i18n library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Extract from the built HTML, not the templates
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;a&lt;/code&gt;, &lt;code&gt;strong&lt;/code&gt;, &lt;code&gt;em&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;span&lt;/code&gt;…) inside a block element. The inline tags become numbered placeholders, so translators never see raw HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTML:  Open &amp;lt;a href="…"&amp;gt;bookingticket.dpm.org.cn&amp;lt;/a&amp;gt; at &amp;lt;strong&amp;gt;20:00 Beijing time&amp;lt;/strong&amp;gt;.
unit:  Open &amp;lt;1&amp;gt;bookingticket.dpm.org.cn&amp;lt;/1&amp;gt; at &amp;lt;2&amp;gt;20:00 Beijing time&amp;lt;/2&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic tags (&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;, inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;) become self-closing placeholders like &lt;code&gt;&amp;lt;3/&amp;gt;&lt;/code&gt;. The original tags are kept in a side table and put back verbatim after translation, so attributes, classes and URLs can't be damaged.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It also extracts a few non-visible strings that matter for search and accessibility: &lt;code&gt;alt&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, the meta description and OG tags, and selected keys inside JSON-LD (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;text&lt;/code&gt;) and in the page's own JSON config blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Validate every translation
&lt;/h2&gt;

&lt;p&gt;Each language has a dictionary: &lt;code&gt;{ source_unit: translated_unit }&lt;/code&gt;. Before a translation is used, it goes through a validator. Simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PH&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;(/?)(\d+)(/?)&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;NUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\d+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;empty&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. same placeholders (order may change, the set may not)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;placeholders differ&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. every Chinese string survives verbatim (button labels, app names)
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;zh&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[\u4e00-\u9fff]+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;zh&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;chinese &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;zh&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missing&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="c1"&gt;# 3. every number in the source appears in the translation
&lt;/span&gt;    &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NUM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strip_placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NUM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strip_placeholders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;number &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missing&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="c1"&gt;# 4. no invented numbers (with a small allowance, see below)
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="c1"&gt;# 5. every domain survives
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dom&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[\w-]+\.(?:com|cn|org|net|gov)\b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dom&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dom&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; missing&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I learned writing the rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers are the most important check.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allow some extra numbers, but not many.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chinese text must survive exactly.&lt;/strong&gt; 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.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domains are facts too.&lt;/strong&gt; &lt;code&gt;en.mutianyugreatwall.com&lt;/code&gt; must not become a link to something else, or get a space inserted by a well-meaning tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Publish per page, only if everything passes
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The key rule: &lt;strong&gt;a page is published in a language only if every unit on it has a valid translation.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Because publishing is per page, the SEO bits follow automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;hreflang&lt;/code&gt; alternates on each page list only the languages that page actually exists in, plus &lt;code&gt;x-default&lt;/code&gt; → English.&lt;/li&gt;
&lt;li&gt;Each language gets its own sitemap containing only its published pages, and &lt;code&gt;robots.txt&lt;/code&gt; lists all of them.&lt;/li&gt;
&lt;li&gt;Internal links never point into a language version that doesn't exist. They fall back to the English URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A visible note, and English wins
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone doing the same
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Translate the output, not the templates&lt;/strong&gt;, if your content is mostly prose. It removes a whole class of "forgot to wrap this string" bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write validators for the facts in your domain.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed, per page.&lt;/strong&gt; An untranslated page is a small loss. A wrong translated fact is a real one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've done something similar, especially number handling for languages with different date formats, I'd love to compare notes in the comments.&lt;/p&gt;

&lt;p&gt;The site this runs on is &lt;a href="https://simplechinatrip.com" rel="noopener noreferrer"&gt;Simple China Trip&lt;/a&gt;, a free booking guide for foreign visitors to China.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
