<?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: Yu'an Zhou</title>
    <description>The latest articles on DEV Community by Yu'an Zhou (@yuanjzhou).</description>
    <link>https://dev.to/yuanjzhou</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%2F3997330%2F4386bed3-fe4a-4e5a-946a-520ea9954fbf.jpg</url>
      <title>DEV Community: Yu'an Zhou</title>
      <link>https://dev.to/yuanjzhou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yuanjzhou"/>
    <language>en</language>
    <item>
      <title>What Breaks When Your App Has to Reason About Chinese Characters</title>
      <dc:creator>Yu'an Zhou</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:10:47 +0000</pubDate>
      <link>https://dev.to/yuanjzhou/what-breaks-when-your-app-has-to-reason-about-chinese-characters-1hk1</link>
      <guid>https://dev.to/yuanjzhou/what-breaks-when-your-app-has-to-reason-about-chinese-characters-1hk1</guid>
      <description>&lt;p&gt;Most internationalization advice assumes text is a bag of words separated by spaces. Chinese breaks that assumption at every layer of the stack, and if your product's core value depends on reasoning about individual characters — not just displaying them — you end up rewriting infrastructure you assumed was solved.&lt;/p&gt;

&lt;p&gt;I spend most of my time on a system that analyzes Chinese characters for naming: stroke counts, five-element (五行) classification, tone patterns, and sourcing candidate characters from classical poetry corpora. Here's what actually bit us.&lt;/p&gt;

&lt;h2&gt;
  
  
  A "Character" Is Not a Character
&lt;/h2&gt;

&lt;p&gt;The first thing that breaks is &lt;code&gt;len()&lt;/code&gt;.&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;𠮷田&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# note: the rare variant of 吉, U+20BB7
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# Python 3: 2 -- correct
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3 gets this right because strings are sequences of code points. JavaScript does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;𠮷田&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;            &lt;span class="c1"&gt;// 3  -- UTF-16 surrogate pair counted as two&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;       &lt;span class="c1"&gt;// 2  -- correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any CJK-aware code path in JS must use &lt;code&gt;Array.from()&lt;/code&gt; / spread / &lt;code&gt;for...of&lt;/code&gt;, never &lt;code&gt;.length&lt;/code&gt; or &lt;code&gt;charAt&lt;/code&gt;. This matters immediately for us because a name's stroke-count math is per-character. Counting one character as two silently produces a wrong numerological result — and it never throws, it just returns a plausible wrong answer. Those are the worst bugs.&lt;/p&gt;

&lt;p&gt;The second thing that breaks is your database column. Extension B/C/D characters (U+20000 and up) are 4-byte UTF-8. MySQL's &lt;code&gt;utf8&lt;/code&gt; charset is a 3-byte subset and will truncate or reject them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;characters&lt;/span&gt;
  &lt;span class="k"&gt;CONVERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="nb"&gt;CHARACTER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;utf8mb4&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;utf8mb4_bin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;utf8mb4_bin&lt;/code&gt;, not a general collation. Unicode collations for CJK do fuzzy things you never want in a lookup table — you want exact code point identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalization Will Quietly Destroy Your Data
&lt;/h2&gt;

&lt;p&gt;Everyone reaches for &lt;code&gt;NFKC&lt;/code&gt; because it "cleans up" input. For CJK it is destructive.&lt;/p&gt;

&lt;p&gt;Unicode has a compatibility block of CJK ideographs (U+F900–U+FAFF) that exists purely for round-tripping legacy encodings. NFC and NFKC both map most of them onto the unified codepoint:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;例&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                        &lt;span class="c1"&gt;# CJK COMPATIBILITY IDEOGRAPH-F9B5
&lt;/span&gt;&lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# -&amp;gt; the unified ideograph, different codepoint
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's usually fine. What is not fine is NFKC on mixed content: it converts fullwidth forms to ASCII (&lt;code&gt;Ａ&lt;/code&gt; → &lt;code&gt;A&lt;/code&gt;), collapses fullwidth punctuation, and rewrites halfwidth kana. In a naming corpus, fullwidth vs halfwidth is real signal about the source document.&lt;/p&gt;

&lt;p&gt;Our rule ended up being: &lt;strong&gt;NFC for storage, never NFKC, and normalize once at the ingest boundary.&lt;/strong&gt; Anything that normalizes on read will eventually disagree with an index built before the rule changed.&lt;/p&gt;

&lt;p&gt;Then there's variation selectors. The Ideographic Variation Sequences at &lt;code&gt;U+E0100&lt;/code&gt;+ attach to a base ideograph to select a specific glyph, and they survive NFC. Dedupe candidates with a plain hash set and &lt;code&gt;葛&lt;/code&gt; and &lt;code&gt;葛&lt;/code&gt; + &lt;code&gt;U+E0100&lt;/code&gt; are two entries. We strip variation selectors for the &lt;em&gt;index key&lt;/em&gt; but preserve them on the display string — two fields, not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traditional ↔ Simplified Is Not a Lookup Table
&lt;/h2&gt;

&lt;p&gt;This is where most implementations get sloppy. The mapping is not a bijection.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One simplified character maps to several traditional ones: &lt;code&gt;发&lt;/code&gt; is both 發 (to emit) and 髮 (hair). &lt;code&gt;后&lt;/code&gt; covers 後 (after) and 后 (empress).&lt;/li&gt;
&lt;li&gt;Some conversions are only correct in one direction.&lt;/li&gt;
&lt;li&gt;Context decides, and character-level tables have no context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything user-facing, use OpenCC rather than a homemade table — it does phrase-level conversion, which resolves a good chunk of the ambiguity:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;opencc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenCC&lt;/span&gt;
&lt;span class="n"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenCC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s2twp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# simplified -&amp;gt; traditional, Taiwan idiom
&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;头发&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 頭髮 -- phrase context picks 髮, not 發
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But for a &lt;em&gt;naming&lt;/em&gt; system, phrase context often doesn't exist — you're evaluating a single character in isolation. Our approach: store the character in both scripts as separate rows with an explicit &lt;code&gt;variant_group_id&lt;/code&gt;, and never auto-convert at query time. Let the user pick the script; don't guess.&lt;/p&gt;

&lt;p&gt;Stroke count has the same shape of problem. It's not one number per character — it depends on the script (简 vs 繁), and traditional counts differ between the Kangxi dictionary convention and modern standards for radicals like 艹 (3 strokes in modern counting, 6 under Kangxi where it is treated as 艸). Naming numerology systems typically demand Kangxi counts specifically. If you pull stroke data from a general Unihan field like &lt;code&gt;kTotalStrokes&lt;/code&gt;, you get the modern count, and your results will be off for a large fraction of characters. Unihan is the right source, but you have to read the right property. Getting that right — Kangxi strokes, five-element classification, and tone-pattern checking against 诗经/楚辞 source lines — is the core of what we ship at &lt;a href="https://babynameai.org" rel="noopener noreferrer"&gt;BabyNameAI&lt;/a&gt;, and the stroke-count semantics were more work than the entire model layer sitting on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Indexing Classical Chinese
&lt;/h2&gt;

&lt;p&gt;Classical Chinese has no word delimiters and no reliable segmenter — tools like jieba are trained on modern Mandarin and will happily mis-segment a Tang line. So don't segment.&lt;/p&gt;

&lt;p&gt;Index character n-grams instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Postgres: trigram index sidesteps a CJK-hostile default text parser&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;poem_lines&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Elasticsearch, the built-in &lt;code&gt;cjk&lt;/code&gt; analyzer (which produces bigrams) beats a standard analyzer, and beats &lt;code&gt;ik_smart&lt;/code&gt; for classical text since IK's dictionary is modern vocabulary. Bigrams over a few hundred thousand lines of 诗经/楚辞/唐诗 is a small index and it recalls correctly on the queries that matter: "find every line containing this character in second position of a two-character compound."&lt;/p&gt;

&lt;p&gt;One last trap: tone data. Pinyin tone marks are combining diacritics under NFD (&lt;code&gt;zhōng&lt;/code&gt; decomposes to &lt;code&gt;o&lt;/code&gt; + &lt;code&gt;U+0304&lt;/code&gt;) but precomposed under NFC, so comparing differently-normalized pinyin silently fails. And classical tone categories (平上去入) do not map onto modern Mandarin tones — the entering tone 入声 disappeared in Mandarin but survives in Cantonese and Min. Checking a name against classical prosody rules needs a 中古音 rime table, not modern pinyin.&lt;/p&gt;

&lt;p&gt;None of this is exotic once you've hit it. Every "just use Unicode" tutorial simply stops right before the interesting part.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>python</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shipping a Chrome Extension That Works Across Sites You Do Not Control</title>
      <dc:creator>Yu'an Zhou</dc:creator>
      <pubDate>Fri, 24 Jul 2026 19:21:11 +0000</pubDate>
      <link>https://dev.to/yuanjzhou/shipping-a-chrome-extension-that-works-across-sites-you-do-not-control-b64</link>
      <guid>https://dev.to/yuanjzhou/shipping-a-chrome-extension-that-works-across-sites-you-do-not-control-b64</guid>
      <description>&lt;p&gt;Building a Chrome extension that saves items from arbitrary third-party sites means every site is a hostile, unstable environment. Here is what actually broke, and what held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manifest V3 changes the shape of everything
&lt;/h2&gt;

&lt;p&gt;The persistent background page is gone. Service workers terminate — aggressively, after roughly 30 seconds of inactivity — and any in-memory state dies with them.&lt;/p&gt;

&lt;p&gt;Anything that must survive goes into &lt;code&gt;chrome.storage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ dies with the worker&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ survives&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most common source of "works when I test it, fails in the wild" bugs. Your worker is alive during active testing and dead during real usage patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content script isolation cuts both ways
&lt;/h2&gt;

&lt;p&gt;Content scripts run in an isolated world: you see the DOM, not the page's JavaScript. You cannot read the page's variables, and the page cannot see yours.&lt;/p&gt;

&lt;p&gt;Good for safety, awkward when the data you want lives in a framework's state rather than the DOM. Options are injecting into the main world (more risk, more capability) or scraping rendered DOM (fragile but isolated). I default to DOM scraping and accept the fragility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selectors rot, so degrade instead of breaking
&lt;/h2&gt;

&lt;p&gt;Third-party markup changes without warning. A selector working today breaks next week with no notice and no error you will see.&lt;/p&gt;

&lt;p&gt;What helps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layered fallbacks&lt;/strong&gt; — semantic markup first (&lt;code&gt;article&lt;/code&gt;, &lt;code&gt;[itemprop]&lt;/code&gt;, OpenGraph tags), site-specific selectors only as a last resort&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail visibly, not silently&lt;/strong&gt; — if extraction fails, tell the user rather than saving an empty record&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never assume shape&lt;/strong&gt; — &lt;code&gt;el?.textContent?.trim() ?? ''&lt;/code&gt; everywhere; a null deref in a content script can break the host page, which is much worse than your feature not working&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Permissions: ask for less
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; triggers heavier review and scares users at install time. &lt;code&gt;activeTab&lt;/code&gt; plus &lt;code&gt;optional_host_permissions&lt;/code&gt; requested at first use is a better trade — fewer install-time objections, and review goes faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store review realities
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Justify &lt;strong&gt;every&lt;/strong&gt; permission in the listing, specifically. Vague justifications get rejected.&lt;/li&gt;
&lt;li&gt;Privacy policy required if you touch user data at all.&lt;/li&gt;
&lt;li&gt;First review is slow; updates are usually much faster.&lt;/li&gt;
&lt;li&gt;A permission added later triggers full re-review — worth batching permission changes rather than shipping them one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I shipped one of these for &lt;a href="https://babynameai.org" rel="noopener noreferrer"&gt;BabyNameAi&lt;/a&gt; — cross-site saving, a daily classical-poetry card, and offline lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveat
&lt;/h2&gt;

&lt;p&gt;Extension APIs are still shifting under MV3. Anything you read from before 2023 — including plenty of still-top-ranked results — may describe APIs that no longer exist.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
