<?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: Melisa Wong</title>
    <description>The latest articles on DEV Community by Melisa Wong (@melisawrites).</description>
    <link>https://dev.to/melisawrites</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%2F4176670%2F26b88b9b-99ce-4384-947e-0d95f24fff19.png</url>
      <title>DEV Community: Melisa Wong</title>
      <link>https://dev.to/melisawrites</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/melisawrites"/>
    <language>en</language>
    <item>
      <title>I diffed my Capacitor APK against my web build. 780 files inside it no longer existed.</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:04:53 +0000</pubDate>
      <link>https://dev.to/melisawrites/i-diffed-my-capacitor-apk-against-my-web-build-780-files-inside-it-no-longer-existed-nif</link>
      <guid>https://dev.to/melisawrites/i-diffed-my-capacitor-apk-against-my-web-build-780-files-inside-it-no-longer-existed-nif</guid>
      <description>&lt;p&gt;I signed a release APK that was &lt;strong&gt;585 MB&lt;/strong&gt;. The web build it was supposed to contain is &lt;strong&gt;182.7 MB&lt;/strong&gt;. Compression does not explain a 3.5× gap, so I went looking — and what I found was worse than a size problem: &lt;strong&gt;780 of the 1,397 files inside that APK no longer existed anywhere in my project.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is how the gap was made, how to spot it in about three seconds on your own app, and the guard script I now run before every signing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first guess was wrong in an instructive way
&lt;/h2&gt;

&lt;p&gt;My instinct was "images are heavy". I listed the largest files inside the native project's copy of the web bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; android/app/src/main/assets/public/assets/&lt;span class="k"&gt;*&lt;/span&gt;.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;69240126  Exploration-BM48lXvB.js
11977882  surname_info-BqOd4Hrh.js
  369001  index-DjfizeWu.js
  290721  pinyin_to_script-DzUio84d.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 69 MB chunk called &lt;code&gt;Exploration&lt;/code&gt;. That is a real problem, but not the one I was hunting, because those filenames do not exist in &lt;code&gt;dist/&lt;/code&gt; at all. &lt;code&gt;dist/assets/&lt;/code&gt; contains exactly one JavaScript file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;83249461  dist/assets/index-RqoFan-G.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different build. Not "heavy images shipped next to the app" — &lt;strong&gt;a different bundle with a different code-splitting shape.&lt;/strong&gt; The old one had a 369 KB entry plus a lazily loaded 69.2 MB chunk. The current one is a single 83.2 MB entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diffing the two directories
&lt;/h2&gt;

&lt;p&gt;Two sorted file lists and &lt;code&gt;comm&lt;/code&gt; are enough to make the drift undeniable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;dist &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/web.txt
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;android/app/src/main/assets/public &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/native.txt

&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; /tmp/native.txt /tmp/web.txt | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;   &lt;span class="c"&gt;# in the app, not in the build&lt;/span&gt;
&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-13&lt;/span&gt; /tmp/native.txt /tmp/web.txt | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;   &lt;span class="c"&gt;# in the build, not in the app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;780   # in the app, not in the build
769   # in the build, not in the app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Out of 1,386 files in the build, only &lt;strong&gt;617&lt;/strong&gt; were present in the app. Calling the rest "extra weight" is generous — the two directories were mostly unrelated.&lt;/p&gt;

&lt;p&gt;The orphans accounted for &lt;strong&gt;625.3 MB of 636.2 MB&lt;/strong&gt;, i.e. 98.3% of the payload:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;extension&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.jpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;694&lt;/td&gt;
&lt;td&gt;500.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;83.1 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;41.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then the mapping became obvious. Every orphan had a same-named sibling in the new build with a different extension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;portraits/&lt;/code&gt; — 292 &lt;code&gt;.jpg&lt;/code&gt; → 292 &lt;code&gt;.webp&lt;/code&gt;, identical basenames, identical subtrees&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;soul_trail/&lt;/code&gt; — 379 &lt;code&gt;.jpg&lt;/code&gt; → 379 &lt;code&gt;.webp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;calligraphy/&lt;/code&gt; — 23 &lt;code&gt;.jpg&lt;/code&gt; → 23 &lt;code&gt;.webp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;beasts/&lt;/code&gt; — 73 &lt;code&gt;.png&lt;/code&gt; → 73 &lt;code&gt;.webp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current &lt;code&gt;dist/&lt;/code&gt; has 767 &lt;code&gt;.webp&lt;/code&gt;, 597 &lt;code&gt;.svg&lt;/code&gt;, 18 &lt;code&gt;.png&lt;/code&gt;, and &lt;strong&gt;zero&lt;/strong&gt; &lt;code&gt;.jpg&lt;/code&gt;. It is one image-format migration: JPEG/PNG → WebP. And it was a good migration — the 292 portraits alone went from &lt;strong&gt;434.7 MB to 31.8 MB&lt;/strong&gt;, same 292 images, 13.7× smaller.&lt;/p&gt;

&lt;p&gt;So the real finding is not "my app is bloated". It is: &lt;strong&gt;I had already fixed the bloat, and the fix never reached the binary.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the APK still had the old files
&lt;/h2&gt;

&lt;p&gt;Timestamps settle it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app-release.apk                        Sep  8 19:49:19
android/app/src/main/assets/public     Sep  8 19:48:15
dist/                                  Sep 23 15:24:46
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native copy is 64 seconds older than the APK it produced, and 15 days older than the build it was supposed to represent.&lt;/p&gt;

&lt;p&gt;This is not a Capacitor bug, and I checked the source instead of assuming. In &lt;code&gt;@capacitor/cli&lt;/code&gt; 8.5.0, &lt;code&gt;copyWebDir&lt;/code&gt; is:&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nativeAbsDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webAbsDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nativeAbsDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It wipes the destination and copies fresh. &lt;code&gt;npx cap copy&lt;/code&gt; is a genuine sync, not a merge — so no stale-file accumulation is possible &lt;em&gt;if you run it&lt;/em&gt;. The failure was mine: after the WebP migration I ran &lt;code&gt;./gradlew assembleRelease&lt;/code&gt; directly, because it is faster and because the app compiled. Gradle packaged whatever snapshot was sitting in &lt;code&gt;assets/public&lt;/code&gt;, and nothing in the toolchain had an opinion about it.&lt;/p&gt;

&lt;p&gt;That is the mental model worth internalizing. &lt;code&gt;webDir: 'dist'&lt;/code&gt; does not create a link. It creates a &lt;strong&gt;copy&lt;/strong&gt;, once, at the moment you invoke the command. After that, the native folder is a vendored dependency on your own past build — no version pin, no lockfile, no warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-second check
&lt;/h2&gt;

&lt;p&gt;Hashed filenames are already a build fingerprint. You do not need a new tool to compare them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; app-release.apk assets/public/index.html | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'/assets/[^"]*'&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'/assets/[^"]*'&lt;/span&gt; dist/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;in apk : /assets/index-ChvbOeYn.css  /assets/index-DjfizeWu.js
on disk: /assets/index-FYxpahNp.css  /assets/index-RqoFan-G.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those differ, the APK is not the app you have been testing in the browser. In my case it was not even close: I had been testing a single 83.2 MB entry bundle while signing a build that lazily loaded a 69.2 MB chunk. Same codebase, two different loading behaviours, and the one I validated was the one that never shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this stays invisible for weeks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale builds do not crash.&lt;/strong&gt; An old bundle is a valid bundle. The app launches, renders, and behaves like a slightly older version of itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every local check reads the wrong directory.&lt;/strong&gt; &lt;code&gt;vite dev&lt;/code&gt;, &lt;code&gt;vite preview&lt;/code&gt;, a browser tab — all of them read source or &lt;code&gt;dist/&lt;/code&gt;. &lt;code&gt;assets/public&lt;/code&gt; is only ever read by the packaged app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Totals hide it; file lists don't.&lt;/strong&gt; "636.2 MB vs 182.7 MB" reads as &lt;em&gt;images are heavy&lt;/em&gt;. The correct reading was &lt;em&gt;almost nothing in there is current&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody diffs size between releases.&lt;/strong&gt; Size is the one metric with no assertion attached to it, so it gets looked at only after someone complains about the download.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The guard
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;verify-web-assets.mjs&lt;/code&gt; — no dependencies, ~100 lines. It walks both directories into &lt;code&gt;path → size&lt;/code&gt; maps, reports missing files, orphans (grouped by extension and summed), same-name-different-size files, and optionally pulls &lt;code&gt;assets/public/index.html&lt;/code&gt; out of the APK to compare hashed asset references. Exit code 1 on any drift, so it can sit in front of &lt;code&gt;assembleRelease&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Real output from my project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;web    1386 files, 182.7 MB
native 1397 files, 636.2 MB

MISSING in native: 769

ORPHANS in native: 780 files, 625.3 MB
  .jpg   500.5 MB
  .js     83.1 MB
  .png    41.7 MB
  .css     0.0 MB

APK CONTAINS A DIFFERENT BUILD
  in apk : index-ChvbOeYn.css, index-DjfizeWu.js
  on disk: index-FYxpahNp.css, index-RqoFan-G.js

RESULT: out of sync — run `npx cap copy` and rebuild.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core is not clever:&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;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFile&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;out&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="nf"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orphans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;native&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;web&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also ran it against &lt;code&gt;dist&lt;/code&gt; vs &lt;code&gt;dist&lt;/code&gt; to be sure the "in sync" path is not a false-positive machine: &lt;code&gt;1386 / 1386, RESULT: in sync, exit 0&lt;/code&gt;. A guard that always fails is not a guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in the build process
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The native folder is a build artifact, not source.&lt;/strong&gt; Nothing in it deserves trust, including its timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cap copy&lt;/code&gt; and &lt;code&gt;gradlew&lt;/code&gt; never get separated.&lt;/strong&gt; One line, so half of it cannot be skipped: &lt;code&gt;npx cap sync android &amp;amp;&amp;amp; ./gradlew assembleRelease&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fingerprint the artifact, not the folder.&lt;/strong&gt; The &lt;code&gt;index.html&lt;/code&gt; inside the APK is the cheapest proof of what was actually shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare file lists before comparing totals.&lt;/strong&gt; Totals produce a plausible story ("images") that can be completely wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate the signing step.&lt;/strong&gt; The check is a directory walk; it costs less than typing the keystore password.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is Capacitor-specific in spirit. Any tool that vendors your web output into a native shell — Cordova, Electron, Tauri, a Docker image copied by hand into a server folder — has the same silent-drift failure mode: the copy is a snapshot, the snapshot has no version, and the build system will happily package a snapshot from last month.&lt;/p&gt;

&lt;p&gt;The 585 MB APK was not a compression puzzle. It was a fifteen-day-old decision that nobody had re-run.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://namesoul.net" rel="noopener noreferrer"&gt;NameSoul&lt;/a&gt;, an app that gives you a Chinese name and the historical life behind it, in ten languages. It is one bundled web build inside a native shell, and it now gets diffed against itself before every signing.&lt;/p&gt;

</description>
      <category>capacitor</category>
      <category>android</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>My translation QA passed 31,020 strings. One locale had quietly lost 45% of its text.</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:45:08 +0000</pubDate>
      <link>https://dev.to/melisawrites/my-translation-qa-passed-31020-strings-one-locale-had-quietly-lost-45-of-its-text-2ha9</link>
      <guid>https://dev.to/melisawrites/my-translation-qa-passed-31020-strings-one-locale-had-quietly-lost-45-of-its-text-2ha9</guid>
      <description>&lt;p&gt;I have a content corpus that people actually read: 282 story seeds, each with ten text slots, each slot translated into nine languages. One TypeScript file holds all of it. It is 56 MB.&lt;/p&gt;

&lt;p&gt;Key parity passed. Placeholder integrity passed. Sentence-final punctuation passed. Then I grouped the same data by the batch that produced it, and one locale fell off a cliff.&lt;/p&gt;

&lt;p&gt;This post is the four checks in the order I wrote them, why the first three could not possibly have caught the problem, and the one change to the translation pipeline that came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corpus
&lt;/h2&gt;

&lt;p&gt;The shape matters, so here it is exactly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;282 seeds&lt;/strong&gt; (each one is a fixed piece of editorial content)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 text slots per seed&lt;/strong&gt; — two families of five variants each&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 locale fields per slot&lt;/strong&gt; — the English source, the Chinese source, and nine translations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,820 slots&lt;/strong&gt;, &lt;strong&gt;31,020 locale fields&lt;/strong&gt;, &lt;strong&gt;25,380 of them translated&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything lives in one object keyed by &lt;code&gt;seedId/family/variant&lt;/code&gt;. Translations arrived over about six weeks in batches — roughly one batch per content group — produced by a model, reviewed by me only by spot-reading. That last detail is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1 — key parity (necessary, and nearly useless)
&lt;/h2&gt;

&lt;p&gt;The obvious first check: does every slot have every locale?&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;LOCALES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;LOCALES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;0 missing across 25,380 translated fields.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a genuinely good result, and it told me almost nothing. A key-parity check answers one question — "did the write step crash?" — and it will happily pass on a string that says &lt;code&gt;"Coming soon"&lt;/code&gt; in all nine languages. Presence is not correctness. I still run it first, because a &lt;code&gt;null&lt;/code&gt; in the middle of a render is a crash rather than a quality problem, but I stopped treating a green result as a signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2 — length ratio, and why the baseline has to be per locale
&lt;/h2&gt;

&lt;p&gt;Translation length is not random. For prose against English, it is remarkably stable per target language. Median characters-of-translation ÷ characters-of-English, across 2,820 slots:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;locale&lt;/th&gt;
&lt;th&gt;median ratio&lt;/th&gt;
&lt;th&gt;p01&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ar&lt;/td&gt;
&lt;td&gt;0.822&lt;/td&gt;
&lt;td&gt;0.65&lt;/td&gt;
&lt;td&gt;1.93&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ru&lt;/td&gt;
&lt;td&gt;0.959&lt;/td&gt;
&lt;td&gt;0.74&lt;/td&gt;
&lt;td&gt;2.21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hi&lt;/td&gt;
&lt;td&gt;0.967&lt;/td&gt;
&lt;td&gt;0.77&lt;/td&gt;
&lt;td&gt;2.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;1.060&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;2.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;1.079&lt;/td&gt;
&lt;td&gt;0.86&lt;/td&gt;
&lt;td&gt;2.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;1.087&lt;/td&gt;
&lt;td&gt;0.87&lt;/td&gt;
&lt;td&gt;2.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;td&gt;1.088&lt;/td&gt;
&lt;td&gt;0.49&lt;/td&gt;
&lt;td&gt;1.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;1.144&lt;/td&gt;
&lt;td&gt;0.94&lt;/td&gt;
&lt;td&gt;2.57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;1.174&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;2.74&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Arabic at 0.82 and French at 1.17 is a 43% spread between two healthy locales. Any single global threshold — "flag anything under 60% of English" — is therefore wrong in one direction or the other for most of your languages. You have to calibrate per locale, then compare each string against its own language's baseline:&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;median&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratios&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratios&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;suspicious&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;id&lt;/code&gt; column above: p01 is 0.49 while its median is 1.088. That is not a tail, that is a second population hiding inside the distribution. I did not notice it at the time. Remember it for later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 3 — sentence-final punctuation, and the 750 false alarms
&lt;/h2&gt;

&lt;p&gt;If a translation is cut off mid-generation, it usually loses its final period. So: when the English ends with terminal punctuation and the translation does not, flag it.&lt;/p&gt;

&lt;p&gt;I wrote the character class the way you write one when you are thinking in English:&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;TERM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;.!?。！？…؟।"'”’»)&lt;/span&gt;&lt;span class="se"&gt;\]]\s&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and German explodes: &lt;strong&gt;750 flagged strings out of 2,820.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My first reaction was that the German batch was badly broken. It was not. Every one of those 750 looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EN tail: ...you said: "Each of you. That was the plan."
DE tail: ...Du sagtest: „Jeder von euch. Das war der Plan.“
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;German typographic quotes are &lt;code&gt;„…“&lt;/code&gt; — they open with U+201E and &lt;strong&gt;close with U+201C&lt;/strong&gt;, a character that is a &lt;em&gt;left&lt;/em&gt; double quote everywhere else. My class had &lt;code&gt;”&lt;/code&gt; (U+201D) and &lt;code&gt;’&lt;/code&gt; (U+2019) but not &lt;code&gt;“&lt;/code&gt; (U+201C), because in English &lt;code&gt;“&lt;/code&gt; only ever appears at the start of a quotation. So every German string that ended in dialogue ended in a character my regex considered non-terminal.&lt;/p&gt;

&lt;p&gt;Add one code point and German goes from &lt;strong&gt;750 → 5&lt;/strong&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TERM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;.!?。！？…؟।"'”’»«“‹›)&lt;/span&gt;&lt;span class="se"&gt;\]]\s&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalizes past German: &lt;strong&gt;your punctuation table is locale data, not a constant.&lt;/strong&gt; French spaces its punctuation (&lt;code&gt;« guillemets »&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt; with a preceding thin space). Arabic uses &lt;code&gt;؟&lt;/code&gt; and &lt;code&gt;،&lt;/code&gt;. Hindi uses &lt;code&gt;।&lt;/code&gt;. Japanese and Chinese borrow full-width &lt;code&gt;。&lt;/code&gt; and &lt;code&gt;！&lt;/code&gt;. A single flat class is a bug waiting to fire the first time a locale's typography disagrees with your assumptions — and it fires loudly, in the direction of false positives, which trains you to ignore the check entirely. That is worse than not having it.&lt;/p&gt;

&lt;p&gt;After the fix, the real truncations were clean and obvious:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;locale&lt;/th&gt;
&lt;th&gt;truncated strings (of 2,820)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ru&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pt&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hi&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fr&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;es&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ar&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;de&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;120&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;120 out of 25,380 — &lt;strong&gt;0.47%&lt;/strong&gt;. Every one I opened was cut mid-clause, and the pattern was always the same: the model hit an output limit or a timeout, the client wrote whatever it had, and nothing downstream asked "does this sentence end?"&lt;/p&gt;

&lt;p&gt;These are cheap to fix: re-request just the failing keys. 120 strings is one small batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 4 — the batch dimension, which is where it actually broke
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;id&lt;/code&gt; had the &lt;em&gt;fewest&lt;/em&gt; truncations of any locale. Three. Its median length ratio was 1.088 — the healthiest-looking number in the table. Both of my checks agreed Indonesian was the best-translated language I had.&lt;/p&gt;

&lt;p&gt;Then I did something I should have done on day one: I grouped the ratio by &lt;strong&gt;the batch that produced it&lt;/strong&gt;, not by locale.&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;// batchOf(key) reads the content-group id off the seed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byBatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;batchOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;byBatch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratios&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratios&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-locale median ratio, by batch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;content group&lt;/th&gt;
&lt;th&gt;en avg chars&lt;/th&gt;
&lt;th&gt;cn&lt;/th&gt;
&lt;th&gt;es&lt;/th&gt;
&lt;th&gt;de&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;id&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;group 1&lt;/td&gt;
&lt;td&gt;1,095&lt;/td&gt;
&lt;td&gt;0.31&lt;/td&gt;
&lt;td&gt;1.09&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.13&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 2&lt;/td&gt;
&lt;td&gt;1,328&lt;/td&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;td&gt;1.04&lt;/td&gt;
&lt;td&gt;1.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 3&lt;/td&gt;
&lt;td&gt;1,290&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;td&gt;1.05&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 4&lt;/td&gt;
&lt;td&gt;1,388&lt;/td&gt;
&lt;td&gt;0.27&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 5&lt;/td&gt;
&lt;td&gt;1,290&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;td&gt;1.01&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 6&lt;/td&gt;
&lt;td&gt;1,593&lt;/td&gt;
&lt;td&gt;0.29&lt;/td&gt;
&lt;td&gt;1.07&lt;/td&gt;
&lt;td&gt;1.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 7&lt;/td&gt;
&lt;td&gt;945&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;td&gt;1.04&lt;/td&gt;
&lt;td&gt;1.11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 8&lt;/td&gt;
&lt;td&gt;1,096&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;1.10&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.11&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 9&lt;/td&gt;
&lt;td&gt;1,170&lt;/td&gt;
&lt;td&gt;0.31&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;td&gt;1.22&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.02&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 10&lt;/td&gt;
&lt;td&gt;1,262&lt;/td&gt;
&lt;td&gt;0.31&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 11&lt;/td&gt;
&lt;td&gt;1,518&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;1.06&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 12&lt;/td&gt;
&lt;td&gt;1,741&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;td&gt;1.04&lt;/td&gt;
&lt;td&gt;1.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.57&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group 13&lt;/td&gt;
&lt;td&gt;1,226&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;1.08&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.09&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chinese, Spanish and German are flat across all thirteen groups. Indonesian drops to 0.64 in one group and 0.57 in the next, then returns to 1.09 — as if nothing happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;202 strings, in two groups, roughly 45% shorter than their English source.&lt;/strong&gt; And here is why check 3 missed them: &lt;strong&gt;201 of the 202 end with a proper full stop.&lt;/strong&gt; They are not truncated. They are condensed. The model summarised instead of translating, the output was well-formed, the writer step accepted it, and every structural check went green.&lt;/p&gt;

&lt;p&gt;The damage is not a broken word at the end of a paragraph. It is a paragraph that no longer exists, in a language spoken by a few hundred million people, sitting in a shipped build for six weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the pipeline
&lt;/h2&gt;

&lt;p&gt;Four things, all cheap:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Verify at the write boundary, per key, with three assertions.&lt;/strong&gt; Not "did the response parse" — that was already there — but: the key set returned equals the key set sent; the last character is terminal per a &lt;em&gt;locale-aware&lt;/em&gt; class; the paragraph count matches the source's. A single number check would not have caught the condensing, and a single punctuation check would not have caught it either, but paragraph count diverges the moment a model drops a section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Retry only the failures.&lt;/strong&gt; 120 of 25,380 is 0.47%. Re-requesting those keys as one batch costs a fraction of regenerating a locale, and it keeps the keys that were already good — including the human-reviewed ones you do not want overwritten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Store the baseline as a test fixture.&lt;/strong&gt; The per-locale, per-batch median ratio is now a committed JSON file. Any future batch that lands more than ~25% off its locale's baseline fails CI before it reaches a build. This is a twelve-line test and it would have caught the Indonesian regression on the day it landed, not six weeks later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Keep the batch id on the content, forever.&lt;/strong&gt; This was the actual bug in my QA, not in my translations. A field that says which run produced a string costs nothing and lets you ask "was this one bad batch or a systemic decay?" — which is the difference between a targeted re-run and a two-week audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape of the mistake
&lt;/h2&gt;

&lt;p&gt;The three checks I wrote first all measured &lt;em&gt;the artifact&lt;/em&gt;. Key present, ratio plausible, ends with a period. The failure was in &lt;em&gt;the process that produced the artifact&lt;/em&gt;, and process failures are invisible unless the process leaves a trace you grouped by.&lt;/p&gt;

&lt;p&gt;If I had to reduce this to one sentence: &lt;strong&gt;aggregate per-locale metrics are an average over your batches, and an average is exactly the thing that hides one batch going wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I build NameSoul, an app that gives people a Chinese name and a story behind it — the corpus above is its content, in ten languages, and this is the audit that made me trust it.&lt;/p&gt;

</description>
      <category>i18n</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>I generated 87 pages from one template. Then I measured how similar they were.</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:09:12 +0000</pubDate>
      <link>https://dev.to/melisawrites/i-generated-87-pages-from-one-template-then-i-measured-how-similar-they-were-12if</link>
      <guid>https://dev.to/melisawrites/i-generated-87-pages-from-one-template-then-i-measured-how-similar-they-were-12if</guid>
      <description>&lt;p&gt;Programmatic pages are easy to ship and hard to audit. You write one template, feed it a list, and get a directory of HTML files. Nothing errors. Every page renders. The nav works, the sitemap validates, and the whole thing looks finished.&lt;/p&gt;

&lt;p&gt;The part that isn't finished is the part you can't see: how much of each page is actually about &lt;em&gt;that&lt;/em&gt; page, and whether the machine-readable files describing your site agree with the files themselves.&lt;/p&gt;

&lt;p&gt;I shipped 87 pages that way, then went back and measured them properly. Five of the numbers were not what I expected. Here they are, with the scripts that produced them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One Node script, no framework, no bundler. Input is a JSON array of intents:&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;// intents.json&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;michael-in-chinese&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;family&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transliteration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Michael&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chinese-names-that-mean-courage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;family&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meaning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;courage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry becomes &lt;code&gt;dist/&amp;lt;slug&amp;gt;/index.html&lt;/code&gt;. The renderer is a template literal that returns a string; the build writes it out. Across 87 pages: average 1,034 words of body text, roughly 18 KB of HTML per page, and zero &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; on any content page — plain HTML with inline CSS, which is the one decision here I'd make again without hesitating.&lt;/p&gt;

&lt;p&gt;Then I wrote a measurement pass that reads &lt;code&gt;dist/&lt;/code&gt; back and ignores the build's own bookkeeping. That distinction matters, and section 2 is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Similarity is a number. Measure it before you ship.
&lt;/h2&gt;

&lt;p&gt;"Thin content" and "duplicate content" get argued about as vibes. They're measurable. Shingle the text at n=5 words and take the Jaccard index of every pair:&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;shingles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jaccard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;small&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;large&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;small&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;large&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results over all 3,741 pairs of 87 pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mean Jaccard &lt;strong&gt;0.106&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;max &lt;strong&gt;0.664&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;min &lt;strong&gt;0.002&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;average 1,010 distinct 5-grams per page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A mean of 0.106 sounds great, and it is misleading. The site has three page families, and cross-family pairs sit near zero (the 0.002 minimum is a real pair that shares almost nothing), which drags the average down. The number that matters is &lt;em&gt;within&lt;/em&gt; a family. Two adjacent transliteration pages score &lt;strong&gt;0.462&lt;/strong&gt; against each other, and &lt;strong&gt;59.6%&lt;/strong&gt; of page A's 5-grams also appear in page B.&lt;/p&gt;

&lt;p&gt;So two pages targeting genuinely different search intents are 60% the same text. That's the actual shape of the problem, and the site-wide average hid it completely.&lt;/p&gt;

&lt;p&gt;The maximum pair was worth finding too: &lt;strong&gt;0.664&lt;/strong&gt; between two privacy pages I wrote months apart and never diffed. Nobody would have caught that by reading.&lt;/p&gt;

&lt;p&gt;What I changed: report similarity per family rather than per site, and fail the build if any intra-family pair exceeds 0.35. Getting under 0.35 forces each page to carry at least one block that is structurally impossible for a sibling to reuse — the actual transliteration table for that specific name, the actual character-by-character breakdown. Boilerplate paragraphs about "why Chinese names are meaningful" will never get you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your sitemap is probably generated from the wrong source
&lt;/h2&gt;

&lt;p&gt;Mine was built from &lt;code&gt;intents.json&lt;/code&gt; — the list of pages I &lt;em&gt;meant&lt;/em&gt; to publish. The site is built from &lt;code&gt;dist/&lt;/code&gt;. Those two drift apart the moment a slug is renamed, a page is retired, or a tool page gets added by hand.&lt;/p&gt;

&lt;p&gt;Measured drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;directories in &lt;code&gt;dist/&lt;/code&gt;: &lt;strong&gt;92&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; entries in &lt;code&gt;sitemap.xml&lt;/code&gt;: &lt;strong&gt;87&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;directories absent from the sitemap: &lt;strong&gt;5&lt;/strong&gt; (one interactive tool page, four asset directories)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is fatal, but it's the wrong shape of truth. A sitemap claiming authority over a set of URLs it never looked at is a document written by hand pretending to be generated.&lt;/p&gt;

&lt;p&gt;The fix is to derive it by walking the output directory and assert the counts match:&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same bug class as a hand-maintained import list: any file that describes a set must be generated &lt;em&gt;from&lt;/em&gt; that set, never alongside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; was recording my build, not my edits
&lt;/h2&gt;

&lt;p&gt;My sitemap stamped &lt;code&gt;lastmod&lt;/code&gt; with the build date. Distribution across 87 URLs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;53 URLs: &lt;code&gt;2026-10-20&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;22 URLs: &lt;code&gt;2026-10-21&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;12 URLs: &lt;code&gt;2026-10-22&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;1 URL: &lt;code&gt;2026-10-13&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fifty-three different pages, all claiming to have changed on the same day. Most had not changed at all — the build simply re-ran after I edited a shared component. That's how you teach a crawler to ignore the field.&lt;/p&gt;

&lt;p&gt;The fix is a content hash that outlives the process:&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;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderedHtml&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;lastmod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a rebuild that changes nothing produces a sitemap that changes nothing. That is the entire point of the field, and it requires state outside the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Title and description lengths are a distribution, not a setting
&lt;/h2&gt;

&lt;p&gt;I checked one page's &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; while building it and it looked fine. Across all 87:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;title length: avg &lt;strong&gt;68&lt;/strong&gt;, max &lt;strong&gt;80&lt;/strong&gt;, min &lt;strong&gt;44&lt;/strong&gt; — &lt;strong&gt;77 of 87 over 60 characters&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;meta description: avg &lt;strong&gt;172&lt;/strong&gt;, max &lt;strong&gt;216&lt;/strong&gt; — &lt;strong&gt;54 of 87 over 160 characters&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;85 of 87&lt;/strong&gt; titles end with the same &lt;code&gt;| Brand&lt;/code&gt; suffix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The length problem is a template problem. One long h1 pattern plus one brand suffix fits the shortest page comfortably and overshoots the longest by twenty characters. There is no single value you can set; you have to check per page and let it fail.&lt;/p&gt;

&lt;p&gt;The suffix problem is worse than the length problem. Ten characters of every title spent on a word nobody searches for, repeated across 85 pages, to reinforce a brand the domain name already states. It belongs on the homepage and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Don't generate the link graph from the template
&lt;/h2&gt;

&lt;p&gt;Every page linked to the same 21 internal URLs. 87 pages × 21 identical links is very close to a complete graph — every page is equally important, which means none of them is.&lt;/p&gt;

&lt;p&gt;Internal links are the one ranking-adjacent signal you fully control, and a uniform template throws it away. Rank candidates by data affinity — same family, shared subject, adjacent concept — then emit the top four to six. Two rules did most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never emit a link to a page in the same family that scores above the similarity threshold (you already decided those two are too alike to both deserve ranking)&lt;/li&gt;
&lt;li&gt;never emit more than six; a link block that lists everything is navigation, not a recommendation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The guardrail: six assertions that fail the build
&lt;/h2&gt;

&lt;p&gt;None of this is worth anything as a one-time cleanup. It has to be a &lt;code&gt;verify.js&lt;/code&gt; that runs after the renderer and exits non-zero:&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;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sitemap count matches dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;sitemapUrls&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;distDirs&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no orphan directories&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nx"&gt;orphans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no bulk lastmod stamping&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nx"&gt;maxPerDate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;titles &amp;lt;= 60 chars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="nx"&gt;titles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;descriptions &amp;lt;= 160 chars&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="nx"&gt;descs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;intra-family jaccard &amp;lt;= 0.35&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;worstPair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jaccard&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All six failed on the first run. Three passed by the second. The similarity assertion took real work, because the only way under the threshold is to give every page content that comes from data specific to it.&lt;/p&gt;

&lt;p&gt;The general lesson is the one I keep re-learning: programmatic pages don't fail loudly. No exception, no red build step, no 404. You get 87 files that render, a sitemap that validates, and then the slow realisation that the site is one page printed 87 times with a noun swapped. The only way to know which of those you built is to measure the output instead of the input.&lt;/p&gt;

&lt;p&gt;I build NameSoul — the site above is what it looks like when a very small team generates its content instead of writing it.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
    </item>
    <item>
      <title>A Chinese Name Is Two Records That Never Meet</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:15:29 +0000</pubDate>
      <link>https://dev.to/melisawrites/a-chinese-name-is-two-records-that-never-meet-2bdm</link>
      <guid>https://dev.to/melisawrites/a-chinese-name-is-two-records-that-never-meet-2bdm</guid>
      <description>&lt;p&gt;A naming app looks like a string generator from the outside. You put in a surname, a gender, a style and a date, and two characters come out.&lt;/p&gt;

&lt;p&gt;Generating the characters is the least interesting thing in NameSoul. What took the time is the two records that hang off the name, and the fact that they are keyed by different things and never joined to each other.&lt;/p&gt;

&lt;p&gt;(Ep.4 of a short series about building this.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Inputs, outputs, and what the name actually is
&lt;/h2&gt;

&lt;p&gt;Input: a surname, a gender, a style, a date of birth.&lt;/p&gt;

&lt;p&gt;Output, in the order the user meets it: a given name; a historical figure who shares the surname; a past-life figure matched to the name's temperament; the places tied to that figure; then six small artifacts, which are a voice clip, the bilingual text, the surname in seal script, the full name in brush, a colour derived from the birth date, and a personal seal.&lt;/p&gt;

&lt;p&gt;The name is the index. Everything else is a lookup through it.&lt;/p&gt;

&lt;p&gt;That is the whole architecture, and it is deliberately shallow. There is no model deciding anything at runtime. The decisions were made before the app existed, one record at a time, and the app just walks the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record A is keyed by surname
&lt;/h2&gt;

&lt;p&gt;The first record is "who carried this surname."&lt;/p&gt;

&lt;p&gt;Pick Li. Under the name, one line appears: this name shares a lineage with Li Bai. Then a few lines about him, and one moment from his life where he was most himself.&lt;/p&gt;

&lt;p&gt;The key here is the surname and nothing else. It does not care about the given name, the style, or the birth date. If two users with different given names both pick Li, they get the same Record A. That is intentional.&lt;/p&gt;

&lt;p&gt;Why key it on the surname? Because the surname is the part of a Chinese name nobody chooses. It arrives with you. If a name is going to have roots, the surname is where they are. So this lookup answers one question only: who stood behind this character before you did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record B is keyed by temperament
&lt;/h2&gt;

&lt;p&gt;The second record is "whose nature does this name carry."&lt;/p&gt;

&lt;p&gt;This one is not keyed on the surname. It is keyed on the given name's meaning, imagery, and feel. The figure who comes back may share nothing with you. Different surname, different era, different life. What matches is the quality the name points at.&lt;/p&gt;

&lt;p&gt;Two different keys, two different questions. A gives you a lineage. B gives you a direction. One looks back, one looks forward, and they never need to agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not merge them into one record
&lt;/h2&gt;

&lt;p&gt;Merging is the obvious simplification, and it breaks the product in a specific way. If one person had to satisfy both conditions, the pool would collapse to "famous people whose surname also happens to fit this name's temperament." You would see the same handful of names repeatedly, and the second half of the app would become a lookup table of four people.&lt;/p&gt;

&lt;p&gt;Keeping them separate costs one join that never happens and buys a much larger pool on each side. That trade was easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The selection problem was harder than the writing
&lt;/h2&gt;

&lt;p&gt;I assumed writing would be the bottleneck. It wasn't. Choosing was.&lt;/p&gt;

&lt;p&gt;Chinese history has more named figures with real personalities and documented lives than anyone can hold. Including all of them is impossible and would also be worse. So the filter came down to three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Famous first. Household names in China, at least recognizable abroad. They act as entry points, because recognition is what makes a user keep reading.&lt;/li&gt;
&lt;li&gt;Then the ones nobody outside China knows, whose lives are still extraordinary. Some are not even well known inside China. The interesting part of a culture usually lives here.&lt;/li&gt;
&lt;li&gt;Temperament has to match. This was the hard filter, and the one I refused to bend. You cannot take a famous person and attach them to a name because the name is popular. Something in that person has to actually answer the name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After selecting, there is still the research: collected works, historical records, biographies, hunting for the one vivid fragment. Where was he standing. What did he say. Which poem. Which choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limit
&lt;/h2&gt;

&lt;p&gt;I can't exhaust Chinese history, and I'm not going to pretend the coverage is complete. What I do is select and match, and keep every entry sourced rather than invented. The standard I hold is "this figure is real, this moment is documented, this quality is his." The standard I can't promise is "nothing important is missing."&lt;/p&gt;

&lt;h2&gt;
  
  
  Places are a third lookup
&lt;/h2&gt;

&lt;p&gt;The places hang off Record B, not off the name: cities he lived in, mountains and rivers he crossed, what he left behind, where he was buried.&lt;/p&gt;

&lt;p&gt;I added them because a name that ends at a person is a fact, and a name that ends at a landscape is a reason. From a name to a person to a city to a place you could stand in. That last step is the only one I actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the split bought
&lt;/h2&gt;

&lt;p&gt;One record would have been simpler to build and much worse to use. Two records means the name can be new every time while the historical layer stays honest, and it means the same user can come back, change the style, get a different name, and land on a completely different person without the surname record being disturbed.&lt;/p&gt;

&lt;p&gt;The whole app is this: a short path through records that were chosen slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;If you were building this, what would you key Record B on? Temperament is the answer I landed on, and it is the least defensible one on paper, because "temperament" is not a field you can query.&lt;/p&gt;

&lt;p&gt;I'd genuinely like to hear how someone else would model it. I made NameSoul, it's at namesoul.net, and the parts I'm least sure about are exactly the parts I'd most like argued with.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>design</category>
      <category>discuss</category>
      <category>culture</category>
    </item>
    <item>
      <title>The 83 MB JavaScript bundle was not a dependency problem</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:04:43 +0000</pubDate>
      <link>https://dev.to/melisawrites/the-83-mb-javascript-bundle-was-not-a-dependency-problem-5d0e</link>
      <guid>https://dev.to/melisawrites/the-83-mb-javascript-bundle-was-not-a-dependency-problem-5d0e</guid>
      <description>&lt;p&gt;I ran &lt;code&gt;ls -lh&lt;/code&gt; on a production build last week and got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rw-r--r--  1 user  staff   3.8K  index-FYxpahNp.css
-rw-r--r--  1 user  staff    79M  index-RqoFan-G.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy-nine megabytes of JavaScript. One file. One line, 13,308,122 characters long.&lt;/p&gt;

&lt;p&gt;The stylesheet, for comparison, is 3.8 KB. The CSS is a rounding error next to the script.&lt;/p&gt;

&lt;p&gt;This is a React app packaged with Capacitor, so there is no CDN and no lazy route to hide behind. That 79 MB file is inside the app binary, read from local storage on every cold start, and parsed before the first screen paints.&lt;/p&gt;

&lt;p&gt;This post is the audit I should have run two months earlier, the three reasons the usual tooling missed it, and the split that brought the entry chunk down to something reasonable.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a bundle gets to 79 MB without anyone noticing
&lt;/h2&gt;

&lt;p&gt;Nobody adds 79 MB of JavaScript on purpose. You add a data file.&lt;/p&gt;

&lt;p&gt;This app is a content product. Every record it ships carries two text sections — one shown under the main illustration, one in a separate panel — and each section exists in several variants so a user does not see the same paragraph twice. Every string is localized into ten languages, with the source text kept alongside.&lt;/p&gt;

&lt;p&gt;Text meant to be read is not redundant, so it compounds fast. A few hundred records multiplied across sections, variants, and languages is already tens of thousands of strings — and all of it was living in a source module.&lt;/p&gt;

&lt;p&gt;The data lived in a module like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ContentDB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group-male-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sectionA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;A&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&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="na"&gt;es&lt;/span&gt;&lt;span class="p"&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="cm"&gt;/* more */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="cm"&gt;/* more variants */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sectionB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;A&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group-male-02&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// ... hundreds more assignments&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it was imported the normal way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data/content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole story. A single TypeScript data file that had grown well past 50 MB became a 79 MB minified chunk, and the build reported success.&lt;/p&gt;

&lt;p&gt;Two details matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is an imperative build, not a literal.&lt;/strong&gt; &lt;code&gt;db[key] = {...}&lt;/code&gt; is a side effect. Bundlers cannot tree-shake it, cannot see the shape of the object, and cannot split it. You get one evaluation unit, all or nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is imported by the entry module.&lt;/strong&gt; Even if the data were a plain object literal, a static import from &lt;code&gt;main.tsx&lt;/code&gt; puts it in whatever chunk &lt;code&gt;main.tsx&lt;/code&gt; lives in. In this build there is exactly one JS file, so "the entry chunk" and "all of the app's content" are the same artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the bundle analyzer was quiet
&lt;/h2&gt;

&lt;p&gt;I ran the analyzer. It showed a clean report: React, React DOM, a router, a state library, a date library. Nothing over 200 KB. Total dependency weight looked perfectly healthy.&lt;/p&gt;

&lt;p&gt;The analyzer was not lying. It lists &lt;strong&gt;packages&lt;/strong&gt;. My weight was in &lt;code&gt;./src/data&lt;/code&gt;, which is my own code, and own code does not get a row unless it is a big named import. The tool answered the question I asked ("which packages are heavy?") instead of the question I had ("which bytes are large?").&lt;/p&gt;

&lt;p&gt;Three checks would have caught it in seconds, and none of them are on by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. raw size of every emitted chunk, sorted&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lS&lt;/span&gt; dist/assets/&lt;span class="k"&gt;*&lt;/span&gt;.js | &lt;span class="nb"&gt;head&lt;/span&gt;

&lt;span class="c"&gt;# 2. gzip size, because raw size alone scares people about the wrong things&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;dist/assets/&lt;span class="k"&gt;*&lt;/span&gt;.js&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%8s raw  %8s gz  %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 3. the tell: a single line longer than 100 KB is inlined data, not logic&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{ if (length($0) &amp;gt; 100000) print FILENAME": line "NR" = "length($0)" chars" }'&lt;/span&gt; dist/assets/&lt;span class="k"&gt;*&lt;/span&gt;.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one is the one I now trust most. Minified &lt;em&gt;code&lt;/em&gt; is long-lined but bounded. A minified &lt;em&gt;data literal&lt;/em&gt; produces one absurd line, and in this build that line is 13.3 MB. If you see that, stop looking at your dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gzip does not rescue you
&lt;/h2&gt;

&lt;p&gt;Compressed, the chunk goes from 83,249,461 bytes to 31,707,388 — a 2.62× reduction. That is worse than typical for code (usually 3–4×), because prose in ten languages is not as redundant as machine-generated identifiers.&lt;/p&gt;

&lt;p&gt;So even after compression: 30 MB. And compression only helps the transfer, not the parse. The engine still has to parse and evaluate 83 MB of source on the device. Parse-only cost on my laptop, measured with &lt;code&gt;node --check&lt;/code&gt; (no execution, no React, no rendering):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real 2.80
real 1.92
real 1.47
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it 1.5–2.8 seconds, before any of my code runs, on an M-series Mac. A mid-range Android phone is not faster at this than a laptop. That is the entire cold-start budget spent on reading paragraphs that the user will not see for another four taps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is specifically worse inside a packaged app
&lt;/h2&gt;

&lt;p&gt;On the web, this is a bad first-load story. In a Capacitor build it is three separate problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install size.&lt;/strong&gt; With images and fonts included, what ships on disk is roughly twice the size of that script alone. All of it is copied into the binary. Store listing pages show that number to users before they have seen a single screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No delivery tricks.&lt;/strong&gt; No CDN, no HTTP caching between releases, no range requests, no "load the rest later" over the network. The WebView reads the file from disk. Every improvement has to happen at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates are whole.&lt;/strong&gt; A fix to one typo in one paragraph can produce a new binary, because the paragraph is compiled into the bundle. There is no such thing as a small content patch.&lt;/p&gt;

&lt;p&gt;On the web you would split the route and ship a spinner. In a packaged app the same weight shows up as install friction and cold start, and no RUM dashboard will page you about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;I did not shrink the content. Truncating a content product to hit a byte budget is solving the wrong problem. I moved the content out of the parse path.&lt;/p&gt;

&lt;p&gt;The key detail is that the record keys are already hierarchical — &lt;code&gt;group-male-01&lt;/code&gt;, &lt;code&gt;group-female-03&lt;/code&gt; — so the first segment is a natural partition. Instead of one module that contains everything, the build now emits one JSON file per group and a small manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/content/manifest.json      # { "&amp;lt;group&amp;gt;": &amp;lt;count&amp;gt;, ... }
public/content/&amp;lt;group&amp;gt;.json       # records for that group
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loader is about thirty lines and does not need a framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Manifest&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/content/manifest.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`unknown group: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/content/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`content &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// JSON.parse beats JS parse per byte, and it cannot execute anything&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things made this work in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt; on a local origin works in the WebView.&lt;/strong&gt; A bundled Capacitor app still serves &lt;code&gt;capacitor://localhost&lt;/code&gt; (or &lt;code&gt;http://localhost&lt;/code&gt; on Android) through its internal scheme handler. Relative &lt;code&gt;fetch&lt;/code&gt; to a file inside &lt;code&gt;dist/&lt;/code&gt; behaves like a normal static request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JSON.parse&lt;/code&gt; is cheaper than executing a JS object literal.&lt;/strong&gt; Both are linear in bytes, but the JSON parser does not build a scope, does not evaluate expressions, and can bail out on malformed input instead of running it. For pure data, JSON is the right container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The variant split happens after load, not before.&lt;/strong&gt; All variants come down with the record. Splitting to one variant per request would have cut bytes on paper and tripled the request count for a user who swipes through the gallery — the wrong trade on a device where every request costs a tick.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The entry chunk now carries routing, state, and UI. Content arrives per group, and the first screen requests one file for the one group it is showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The budget check I keep in CI
&lt;/h2&gt;

&lt;p&gt;The rule I settled on is boring and has caught two regressions since:&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;// scripts/budget.mjs — runs after build, exits non-zero on breach&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_LIMIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// 2 MB per chunk, raw&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LINE_LIMIT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 100 KB in one line = inlined data&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;CHUNK_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`chunk over budget: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;LINE_LIMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`inlined data suspected: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; longest line = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;longest&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line-length check is a heuristic, and I know it. It is also the only check in the file that ever predicted a real problem before a human noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone packaging a content app
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure the artifact, not the dependency tree.&lt;/strong&gt; The analyzer answers "which packages are heavy". Data you authored is invisible to it, and data you authored is usually the weight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep content out of the parse path from day one.&lt;/strong&gt; Not "we will split it later" — later means after the prose has grown past 50 MB and every key is already compiled in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a long single line as a bug signal in review.&lt;/strong&gt; It costs one &lt;code&gt;awk&lt;/code&gt; and it is unambiguous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression is a transfer story, not a runtime story.&lt;/strong&gt; 2.62× smaller on the wire; the same bytes to parse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It is the ordinary consequence of a content product whose content was stored where code goes.&lt;/p&gt;




&lt;p&gt;I build NameSoul, an app that gives people a Chinese name along with the historical story behind it — a content-heavy product in ten languages, and, for a while, one very large JavaScript file. If you are shipping a text-heavy app through Capacitor, the numbers above are yours to reuse: &lt;a href="https://namesoul.net" rel="noopener noreferrer"&gt;namesoul.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My i18n function takes eleven positional arguments. Here is what that cost me.</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Wed, 14 Oct 2026 14:38:24 +0000</pubDate>
      <link>https://dev.to/melisawrites/my-i18n-function-takes-eleven-positional-arguments-here-is-what-that-cost-me-27hp</link>
      <guid>https://dev.to/melisawrites/my-i18n-function-takes-eleven-positional-arguments-here-is-what-that-cost-me-27hp</guid>
      <description>&lt;p&gt;Let me start with the real signature, because it explains everything that follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AppLanguage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;esText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ruText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ptText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;frText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;itText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;arText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;deText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;hiText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;idText&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;esText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;esText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ruText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ruText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ptText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ptText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;frText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;frText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;itText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;itText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;arText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;arText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;deText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;deText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hiText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hiText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;idText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;idText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven positional arguments. One &lt;code&gt;if&lt;/code&gt; per language. English as the fallback. No keys, no lookup table, no &lt;code&gt;t('some.nested.key')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the entire UI translation layer of an app that ships in ten languages, and it is called roughly 150 times.&lt;/p&gt;

&lt;p&gt;I wrote it, and it works. But I want to walk through what it cost me, because the cost is not where you would expect it to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wrote it this way
&lt;/h2&gt;

&lt;p&gt;I want to be fair to the original decision, because it was not careless. It was a trade I made on purpose.&lt;/p&gt;

&lt;p&gt;The alternative is the standard key-based API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createName.firstName&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// → 'Nombre / 名'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this project, the problem with that was friction at the call site. When I write UI, I want to see the actual string I am rendering. With &lt;code&gt;mainText(...)&lt;/code&gt; I get the English, the Spanish, the Arabic — all of it — right there in the JSX. I never have to open a second file to know what the user reads. And when I add a language, TypeScript hands me a signature that lists every slot, so the editor tells me what to fill in.&lt;/p&gt;

&lt;p&gt;For a solo project with a fixed set of languages, that is genuinely pleasant. It is a local-optimization win.&lt;/p&gt;

&lt;p&gt;It is also a trap. Here is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost 1 — parameter order becomes an invisible contract
&lt;/h2&gt;

&lt;p&gt;Read the order again: &lt;code&gt;en, es, ru, pt, fr, it, ar, de, hi, id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is not alphabetical. I ordered them by when I happened to write them, which is exactly the kind of ordering that feels obvious to the person who chose it and is invisible to everyone else — including me, three months later.&lt;/p&gt;

&lt;p&gt;Now look at a real call site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;mainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Listen&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escuchar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Слушать&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ouvir&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Écouter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ascolta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;استمع&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hören&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;सुनें&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Dengar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask yourself: if you were writing this line late at night, which slot would you get wrong?&lt;/p&gt;

&lt;p&gt;Nothing enforces the order. Every argument is a &lt;code&gt;string | undefined&lt;/code&gt;. If you swap the Portuguese and Russian slots, TypeScript is completely happy. You ship a Russian string to Portuguese users, the build passes, and the only thing that catches it is a human reading the Portuguese UI.&lt;/p&gt;

&lt;p&gt;That is the worst class of bug: &lt;strong&gt;it type-checks, and it is silent.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost 2 — a missing argument fails silently, in English
&lt;/h2&gt;

&lt;p&gt;Look at the fallback again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;esText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;esText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;en&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conditions must both hold: the language matches, &lt;em&gt;and&lt;/em&gt; the string is present. Forget to pass the Spanish string, and &lt;code&gt;esText&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the condition fails, and the function returns English.&lt;/p&gt;

&lt;p&gt;Nothing throws. Nothing logs. The Spanish user simply sees one English button in an otherwise Spanish screen.&lt;/p&gt;

&lt;p&gt;With ten languages and ~150 call sites, that is not hypothetical — it is a matter of time. And because English is a legitimate, expected outcome, you cannot flag it at runtime without drowning in noise.&lt;/p&gt;

&lt;p&gt;The fix is boring, and it took me too long to apply: make the arguments required instead of optional, and add a dev-only assertion that every language has a value. Optional parameters trade compile-time safety for call-site convenience, and in an i18n layer that is the wrong trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost 3 — the source language is not a language
&lt;/h2&gt;

&lt;p&gt;This one is the most interesting, and it is specific to how this product works.&lt;/p&gt;

&lt;p&gt;Every screen shows the target language on the main line and &lt;strong&gt;Chinese underneath it&lt;/strong&gt;, always. Not behind a toggle. Not as a second language pack. Chinese is rendered alongside every other language because it &lt;em&gt;is&lt;/em&gt; the content — the thing being translated, shown in parallel.&lt;/p&gt;

&lt;p&gt;So the language packs do not look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nombre&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nombre / 名&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;gender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Género / 性别&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Chinese is welded directly into each of the nine packs. One field renders the whole bilingual line, so the UI code stays trivial.&lt;/p&gt;

&lt;p&gt;That has a consequence I got wrong for a while: the app has &lt;strong&gt;no&lt;/strong&gt; Chinese interface, and it never should. Chinese is not the eleventh language — it is the source that all ten languages are stacked on top of. For a while the type union still allowed &lt;code&gt;'zh'&lt;/code&gt;, and a test build that injected &lt;code&gt;ns_lang=zh&lt;/code&gt; would drop the user into a "pure Chinese interface" that did not actually exist. A ghost locale. I sealed it by removing &lt;code&gt;zh&lt;/code&gt; from every runtime whitelist — detection, persistence, selector — while keeping the type honest.&lt;/p&gt;

&lt;p&gt;The lesson generalizes: &lt;strong&gt;decide whether your source language is a language or an input.&lt;/strong&gt; If it is an input, it must not be selectable. Leave it selectable "just in case", and you will eventually ship a fake locale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost 4 — RTL is not &lt;code&gt;dir="rtl"&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Arabic is in the set, so there is one line that flips the document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isRTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isRTL&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rtl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ltr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That handles maybe 80% of it. The remaining 20% is where the bugs live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mixed-direction lines.&lt;/strong&gt; A badge showing a Latin-script name next to Chinese characters does not simply mirror — the runs reorder and the punctuation lands on the wrong side. That needed explicit LTR isolation on the badge itself, not on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Chinese helper line.&lt;/strong&gt; The Chinese sub-line should still read left-to-right inside an RTL page. Let it inherit &lt;code&gt;rtl&lt;/code&gt; and the punctuation jumps to the start of the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbers and Latin text inside Arabic sentences.&lt;/strong&gt; Fine at the document level, broken the moment they sit next to a bracket or a slash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The systematic fix is logical CSS properties: &lt;code&gt;margin-inline-start&lt;/code&gt; instead of &lt;code&gt;margin-left&lt;/code&gt;, &lt;code&gt;padding-inline&lt;/code&gt; instead of &lt;code&gt;padding-left&lt;/code&gt;, &lt;code&gt;text-align: start&lt;/code&gt; instead of &lt;code&gt;left&lt;/code&gt;. Every physical property you write is a future RTL bug you have not found yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost 5 — machine translation hides its own failures
&lt;/h2&gt;

&lt;p&gt;The languages beyond English and Chinese came from batched machine translation. Two things I learned the hard way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Truncation looks like a short translation.&lt;/strong&gt; Some model responses quietly summarize long paragraphs instead of translating them. There is no error — you get a shorter, plausible string. The cheapest detector is a ratio check: flag any translated field whose length falls below ~30% or above ~300% of the source. That one heuristic caught most of my bad fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch size is a reliability knob, not a speed knob.&lt;/strong&gt; Bigger batches finish faster and fail more often, with empty responses under rate limiting. Small batches with a resumable log — key, source, translation, model, request id — let me rerun only what failed and never silently drop a field.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do instead
&lt;/h2&gt;

&lt;p&gt;If I were starting over, the checklist is short:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One exported array of language codes is the single source of truth. The type, the detector, the whitelist and any iteration all derive from it, so adding a language touches one place.&lt;/li&gt;
&lt;li&gt;Translation values live in an object keyed by language code, not in positional slots. A missing language becomes a type error instead of a silent English fallback.&lt;/li&gt;
&lt;li&gt;Required, not optional. If a language must have a value, say so in the type.&lt;/li&gt;
&lt;li&gt;Logical CSS properties from day one — RTL is a writing mode, not a stylesheet rewrite.&lt;/li&gt;
&lt;li&gt;The source language is an input, never a selectable locale.&lt;/li&gt;
&lt;li&gt;Machine translation ships with an audit log and a length-ratio sanity check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The positional signature still works, and at 150 call sites the cost of rewriting it has not yet beaten the cost of leaving it. But it is the one decision in this codebase I would take back first.&lt;/p&gt;

&lt;p&gt;I build NameSoul, a Chinese naming app that currently runs in ten languages. The i18n layer above is the part of it I still think about the most.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>i18n</category>
    </item>
    <item>
      <title>I don't know how to code — I shipped a 10-language app anyway. Here are the 6 decisions.</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:25:13 +0000</pubDate>
      <link>https://dev.to/melisawrites/i-dont-know-how-to-code-i-shipped-a-10-language-app-anyway-here-are-the-6-decisions-3ggi</link>
      <guid>https://dev.to/melisawrites/i-dont-know-how-to-code-i-shipped-a-10-language-app-anyway-here-are-the-6-decisions-3ggi</guid>
      <description>&lt;p&gt;I'm going to skip the inspiration and give you the engineering decisions, because that's what I'd want to read.&lt;/p&gt;

&lt;p&gt;Context: I'm a solo developer with no technical background. I built NameSoul, an app that generates a Chinese name for a non-Chinese speaker and then treats that name as a starting point — six style paths, a real historical figure behind each name, seal-script and calligraphy rendering, audio on every name, ten languages. No team, no funding, no prior code.&lt;/p&gt;

&lt;p&gt;Here are the six decisions that actually determined the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Architecture before features
&lt;/h2&gt;

&lt;p&gt;The first thing I did wasn't writing features. It was mapping the framework: where users enter, what they see first, what they do second, how a name gets generated, what gets displayed afterwards, how the pages connect.&lt;/p&gt;

&lt;p&gt;I drew it out on paper, repeatedly, before letting AI turn any of it into something executable. Skeleton first, flesh second. If you start from features, you end up refactoring your data model three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Learn to talk to AI before you learn to code
&lt;/h2&gt;

&lt;p&gt;My earlier projects all failed, and one big reason was the tooling: AI couldn't follow what I actually meant. I'd say A, it would give me B; I'd explain for ages and it would produce an even more absurd C.&lt;/p&gt;

&lt;p&gt;What changed wasn't the models getting smarter. It's that I learned to decompose. Small, unambiguous, verifiable steps. Describe the interface as a contract, not a vibe. The bottleneck was never my typing speed — it was the specificity of my instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Treat aesthetics as a separate discipline
&lt;/h2&gt;

&lt;p&gt;AI is genuinely good at logic and structure. It is close to useless at visual design. What it produced was functionally correct and visually wrong in a way that's hard to articulate — nothing obviously broken, but something off.&lt;/p&gt;

&lt;p&gt;What worked: referencing. I screenshotted sites whose feel matched what I wanted and said "this direction, this density, this restraint." Concrete references outperform adjectives by an enormous margin.&lt;/p&gt;

&lt;p&gt;I also deliberately cut animations and motion polish I couldn't land well. Shipping something solid beat shipping something half-animated.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Data is a product decision, not a content task
&lt;/h2&gt;

&lt;p&gt;The single most expensive part of this build was not code. It was building a vetted dataset of historical figures, with accurate, English-friendly notes, verified against real geography — cities, landscapes, historical sites.&lt;/p&gt;

&lt;p&gt;Generated bios read smoothly and mean nothing. A curated dataset is dramatically slower, which is exactly why most tools skip it. That asymmetry is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Localization is content, not just strings
&lt;/h2&gt;

&lt;p&gt;Ten languages, and the hard part isn't the UI. Every name explanation and every story has to be translated properly — accurate, natural, with some warmth — then proofread, then checked for layout breakage in that language.&lt;/p&gt;

&lt;p&gt;If you've only ever localized a UI, multiplying that by full content is a different order of magnitude. Plan for it before launch, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Refuse the lazy version, but know which laziness is fatal
&lt;/h2&gt;

&lt;p&gt;Standard advice: ship the crudest usable version, validate demand, then build.&lt;/p&gt;

&lt;p&gt;I only took half of it. Demand validation — sure, but the market already proves the category has demand. The crude-shipping half I rejected, because I'm a user too, and I wouldn't download a version I'd be embarrassed to recommend.&lt;/p&gt;

&lt;p&gt;That said, I did cut scope aggressively elsewhere. "Refuse the lazy version" isn't "build everything" — it's choosing the two or three places where shortcuts would hollow out the product, and taking the shortcut everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell the next person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The architecture diagram is not overhead. It's the thing that keeps you from rewriting yourself into a corner.&lt;/li&gt;
&lt;li&gt;Your prompt quality is your engineering skill right now. Decompose, constrain, verify.&lt;/li&gt;
&lt;li&gt;For visual work, collect references before you ask for anything.&lt;/li&gt;
&lt;li&gt;Budget your time for data and localization. That's where the product actually lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app is live. I'm now going back through it for v1.1 and I'd rather hear what's broken than what's fine.&lt;/p&gt;

&lt;p&gt;Source / product: namesoul.net&lt;/p&gt;

</description>
      <category>sideprojects</category>
      <category>ai</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>From a name to a habit: the 6 product layers behind NameSoul (and what I cut)</title>
      <dc:creator>Melisa Wong</dc:creator>
      <pubDate>Sun, 11 Oct 2026 14:51:53 +0000</pubDate>
      <link>https://dev.to/melisawrites/from-a-name-to-a-habit-the-6-product-layers-behind-namesoul-and-what-i-cut-b4e</link>
      <guid>https://dev.to/melisawrites/from-a-name-to-a-habit-the-6-product-layers-behind-namesoul-and-what-i-cut-b4e</guid>
      <description>&lt;p&gt;I build a Chinese naming app called NameSoul. The interesting part, for this community, isn't the naming — it's that the product is deliberately layered, and every layer exists because the layer below it failed on its own.&lt;/p&gt;

&lt;p&gt;Here's the design, and the things I tore up on the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The premise
&lt;/h2&gt;

&lt;p&gt;People search "give me a Chinese name." Every existing tool answers the same way: input English name → output Chinese name → done. One use. No reason to return.&lt;/p&gt;

&lt;p&gt;I read that as a product smell. A one-shot tool has no retention surface. So the question became: what if the name is not the output, but the input to a longer path?&lt;/p&gt;

&lt;h2&gt;
  
  
  The six layers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Layer 1 — Choice with personality
&lt;/h3&gt;

&lt;p&gt;Six stylistic paths: bold (豪放), poetic (诗意), Five Elements (五行), nature (自然), homophonic (谐音), rustic (田园). These are not cosmetic filters — each path is a different temperament, so the name itself is structurally different. Every result ships with a written rationale: character choice, cultural context, and how tone, form and imagery work together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2 — Grounding in real people
&lt;/h3&gt;

&lt;p&gt;Each name maps to a real historical figure. This is a data problem, not a copywriting problem: a curated set with accurate, English-friendly explanations. No invented characters, no vague "ancient scholar" stand-ins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3 — Making the abstract visible
&lt;/h3&gt;

&lt;p&gt;Enter a surname and it renders in seal script as a calligraphic "totem," plus brush calligraphy for the name itself. This is the layer where users go "I had no idea writing could look like this."&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4 — Time as a coordinate
&lt;/h3&gt;

&lt;p&gt;Birth date produces a lucky color and a personal seal. The framing matters here: it is a personal coordinate, not a prediction. That distinction drove more copy edits than any feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 5 — Story as the payload
&lt;/h3&gt;

&lt;p&gt;The "past life in China": figure, era, cities, landscapes. The design intent is that a user leaves curious about a place, not just a word.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 6 — The retention hook
&lt;/h3&gt;

&lt;p&gt;A daily "shake the stick" — one line, one day. Cheap to build, and it converts a one-time event into a daily habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two implementation details worth stealing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voice clip on every name.&lt;/strong&gt; Foreigners can't read Chinese, and a name you can't pronounce is a name you can't own. Native audio was a small feature and it's the one users mention most.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bilingual everywhere, not just on the result screen.&lt;/strong&gt; An American sees English + Chinese; a French user sees French + Chinese. Chinese isn't the result — it's the environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I cut
&lt;/h2&gt;

&lt;p&gt;The first version leaned much harder on a "fate" framing — fortune-style language that demoed well and felt wrong in product. It flattens a culture into a horoscope. I removed it and rebuilt the copy around history, geography and aesthetics instead. Slower to write, and far more honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;10 languages · 6 styles · real historical figures · 73 guardian beasts · audio on every name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next post
&lt;/h2&gt;

&lt;p&gt;I'll write the technical side: how the seal-script rendering, the multi-language content pipeline, and the audio actually work — including the parts I'd do differently.&lt;/p&gt;

&lt;p&gt;Live app / source of the content: namesoul.net&lt;/p&gt;

</description>
      <category>startup</category>
      <category>design</category>
      <category>showdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
