<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Lexkite Engineering]]></title><description><![CDATA[Lexkite Engineering]]></description><link>https://lexkite.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad3723ab702e2e4bf8d77ba/0ed6ff94-d91c-4f70-ad2b-987c1c1b450a.png</url><title>Lexkite Engineering</title><link>https://lexkite.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 00:51:34 GMT</lastBuildDate><atom:link href="https://lexkite.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a 15-language FSRS vocabulary app in vanilla JS (and exporting real Anki .apkg files in the browser)]]></title><description><![CDATA[Lexkite is a vocabulary and exam-practice app for people preparing for IELTS, TOEIC and TOEFL. It runs as a website, an installable PWA and a Chrome…]]></description><link>https://lexkite.hashnode.dev/building-a-15-language-fsrs-vocabulary-app-in-vanilla-js-and-exporting-real-anki-apkg-files-in-the-browser</link><guid isPermaLink="true">https://lexkite.hashnode.dev/building-a-15-language-fsrs-vocabulary-app-in-vanilla-js-and-exporting-real-anki-apkg-files-in-the-browser</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[PWA]]></category><dc:creator><![CDATA[Lexkite]]></dc:creator><pubDate>Sat, 17 Oct 2026 13:08:30 GMT</pubDate><content:encoded><![CDATA[<p>Lexkite is a vocabulary and exam-practice app for people preparing for IELTS, TOEIC and TOEFL. It runs as a website, an installable PWA and a Chrome extension, with a UI in 15 languages. There is no framework and no build-time bundler. The only <code>devDependencies</code> are <code>axe-core</code> and <code>jsdom</code>, and both are for tests.</p>
<p>This post covers the engineering choices I'd want to read about if someone else had built it: the spaced-repetition scheduler, keeping a 15-language app small, writing <code>.apkg</code> files without a SQLite library, and sharing one codebase between a PWA and an MV3 extension. It also covers one browser feature we removed.</p>
<h2>1. FSRS-5 in about twenty lines</h2>
<p>Flashcard reviews are scheduled with <strong>FSRS-5</strong> (Free Spaced Repetition Scheduler), the algorithm Anki also ships. Each card stores a <strong>stability</strong> <code>S</code> (in days) and a <strong>difficulty</strong> <code>D</code> (1–10). Recall probability decays along a power curve, and the next interval is the point where that curve hits the user's target retention.</p>
<p>Here's the core (from <code>web/js/core.js</code>, trimmed):</p>
<pre><code class="language-js">const DECAY = -0.5, FACTOR = 19 / 81, MAX_IVL = 3650;
const F = {
  // probability of recall t days after the last review
  R: (t, S) =&gt; Math.pow(1 + FACTOR * t / S, DECAY),
  // invert R(t): days until recall falls to the target r
  ivl: (S, r) =&gt; S / FACTOR * (Math.pow(r, 1 / DECAY) - 1),
  // new stability after a successful review with grade G (2..4)
  recallS(D, S, R, G) {
    return S * (Math.exp(W[8]) * (11 - D) * Math.pow(S, -W[9]) *
      (Math.exp(W[10] * (1 - R)) - 1) *
      (G === 2 ? W[15] : 1) * (G === 4 ? W[16] : 1) + 1);
  },
};
</code></pre>
<p><code>W</code> holds the 19 default FSRS-5 weights. The constants are chosen so that at a 0.9 target, <code>ivl(S, 0.9)</code> equals <code>S</code> exactly: "stability" means the number of days until recall drops to 90%. The target retention defaults to 0.9, and users can set it anywhere from 0.8 to 0.95.</p>
<p>The formula alone doesn't make a usable scheduler. These details mattered more than I expected:</p>
<ul>
<li><strong>Learning steps.</strong> New and lapsed cards go through short steps (1 minute, then 10 minutes) before they get a day-based interval, so a word you just met comes back in the same session.</li>
<li><strong>Monotonic buttons.</strong> Rounding can make "Hard" and "Good" show the same interval, which confuses people. We clamp the results so Hard ≤ Good &lt; Easy:</li>
</ul>
<pre><code class="language-js">out[2].ivl = Math.min(out[2].ivl, out[3].ivl);     // Hard ≤ Good
out[3].ivl = Math.max(out[3].ivl, out[2].ivl + 1); // Good &lt; Easy
out[4].ivl = Math.max(out[4].ivl, out[3].ivl + 1);
</code></pre>
<ul>
<li><strong>Fuzz.</strong> Intervals of 3 days or more get ±5% jitter so that cards learned together don't all come due on the same day. Due dates snap to 4 a.m. local time, the same day boundary Anki uses.</li>
<li><strong>Migration.</strong> The first version used SM-2. Old cards don't carry <code>S</code>/<code>D</code>, so on their first FSRS review we estimate them from the stored interval and ease factor instead of resetting anyone's progress.</li>
</ul>
<h2>2. 15 languages inside a 380 KB budget</h2>
<p>The app shell (everything the service worker precaches so the first screen works) has a hard gzip budget. <code>scripts/test/size-budget.mjs</code> runs in <code>npm test</code>, reads the <code>SHELL</code> array straight out of <code>web/sw.js</code>, gzips every file and fails the build if the total goes over:</p>
<pre><code class="language-js">const sw = fs.readFileSync('web/sw.js', 'utf8');
const shell = [...sw.match(/const SHELL = \[([\s\S]*?)\];/)[1]
  .matchAll(/'([^']+)'/g)].map(m =&gt; m[1]);
let shellGz = 0;
for (const u of shell) shellGz += gz(path.join('web', u));
if (shellGz &gt; budget.shellGz) errs.push(`App shell ${kb(shellGz)} gz &gt; budget`);
</code></pre>
<p>The budget is 389,120 bytes (380 KB). The same script caps each lazily loaded data file at 160 KB gzipped, so a large dataset has to be split into chunks, and the total size of <code>web/js</code> is capped as well. Raising any limit needs a reason in the commit message.</p>
<p>A 380 KB shell holds less than you'd think. The English UI dictionary alone is about 100 KB gzipped, so it has to justify every byte. Two things keep it small:</p>
<p><strong>Route-level bundles.</strong> Heavy features (grammar lessons, mock tests, listening sets, the Anki dialog) are lists of plain script files. A route declares which bundles it needs, and <code>App.need</code> loads them in parallel while keeping execution order:</p>
<pre><code class="language-js">const loadOne = src =&gt; new Promise(res =&gt; {
  const s = document.createElement('script');
  s.src = src; s.async = false;            // parallel download, ordered execution
  s.onload = () =&gt; res(true); s.onerror = () =&gt; res(false);
  document.head.appendChild(s);
});
App.need = (...names) =&gt; Promise.all(names.map(n =&gt;
  bundleP[n] || (bundleP[n] = Promise.all(BUNDLES[n].map(loadScript)))));
</code></pre>
<p>On a fast connection with enough memory, the remaining bundles are prefetched during idle time with <code>&lt;link rel=prefetch&gt;</code>. That puts them in the HTTP cache without executing them, so the first page doesn't stall. When <code>saveData</code> is on, the connection is 2G/3G, or the device has 2 GB of RAM or less, we skip the prefetch and load bundles on demand.</p>
<p><strong>Lazy i18n groups.</strong> UI strings live in <code>web/js/i18n/en/&lt;group&gt;.js</code>, and <code>scripts/build-i18n.sh</code> concatenates them into one <code>en.js</code>, except for the groups marked lazy (such as the Anki and report dialogs). Those features load their own strings when they open.</p>
<p>English is written by hand in the same commit as the feature. The other 13 locales are machine-translated from English in a GitHub Action. Short labels (buttons, tabs, nav items) get hand-written translations that always override the machine output. CI then checks every locale for missing keys, broken <code>{placeholders}</code> and stray Vietnamese diacritics. The source keys are Vietnamese because the app started as a Vietnamese-only project, so leftover Vietnamese is the most likely leak.</p>
<p>The most useful rule is easy to miss: <strong>never call <code>t()</code> at module load time.</strong> Language files load asynchronously, so a constant like <code>const LABELS = [t('Review')]</code> gets frozen in the fallback language. Use a function or a getter instead:</p>
<pre><code class="language-js">const LABELS = () =&gt; [t('Ôn tập'), t('Từ mới')];      // evaluated at render time
const mode = { get name() { return t('Luyện nghe'); } };
</code></pre>
<h2>3. Writing real <code>.apkg</code> files in the browser</h2>
<p>Users asked to take their words to Anki. A CSV export would have been easy, but it drops the review schedule. An <code>.apkg</code> file is a ZIP archive containing a SQLite database (<code>collection.anki2</code>). Shipping a SQLite build to the browser for one export button would have blown the budget. So <code>web/js/anki.js</code> writes both formats itself, with no WASM and no ZIP library.</p>
<p><strong>ZIP</strong> is the easy half: a CRC32 table, a local header per file, a central directory, and an end record. Compression comes from the platform:</p>
<pre><code class="language-js">let data = raw, method = 0;                       // 0 = stored
if (canDeflate &amp;&amp; raw.length &gt; 64) {
  const z = await pipeThrough(raw, new CompressionStream('deflate-raw'));
  if (z.length &lt; raw.length) { data = z; method = 8; }  // 8 = deflate
}
const h = new DataView(new ArrayBuffer(30));
h.setUint32(0, 0x04034b50, true);                 // local file header signature
h.setUint16(8, method, true);
h.setUint32(14, crc32(raw), true);
h.setUint32(18, data.length, true); h.setUint32(22, raw.length, true);
</code></pre>
<p><strong>SQLite</strong> is the interesting half. Writing a database is much simpler than running one: there are no updates, no free list and no journal. You only need the on-disk format. Each row becomes a <em>record</em> (a header of serial types plus a body), packed into <em>table B-tree leaf pages</em>. Interior pages are added when one leaf isn't enough, and long payloads spill into overflow pages. Varints are big-endian, 7 bits per byte:</p>
<pre><code class="language-js">function putVarint(b, o, v) {                // v ≥ 0, &lt; 2^56; returns bytes written
  if (v &lt; 128) { b[o] = v; return 1; }
  const g7 = [];
  while (v &gt; 0) { g7.push(v % 128); v = Math.floor(v / 128); }
  const n = g7.length;
  for (let i = 0; i &lt; n; i++) b[o + i] = g7[n - 1 - i] | (i &lt; n - 1 ? 0x80 : 0);
  return n;
}
</code></pre>
<p>(The arithmetic uses <code>Math.floor</code> instead of bit shifts because Anki IDs are millisecond timestamps, which don't fit in 32 bits.) Page 1 begins with the 100-byte header <code>"SQLite format 3\0"</code>, and the <code>sqlite_master</code> table lists the root page of each table.</p>
<p>To check the output, the test suite runs the real <code>sqlite3</code> CLI on the generated file (<code>pragma integrity_check</code> must return <code>ok</code>). It also exports 10,000 cards, re-imports them, and fails if either step takes longer than 6 seconds.</p>
<p><strong>Keeping progress.</strong> If the user opts in, cards that already have a day-based schedule are exported as Anki review cards with a due date, an interval and an ease factor derived from the FSRS difficulty. The raw FSRS memory state goes into the card's <code>data</code> column, so Anki's FSRS can continue from it:</p>
<pre><code class="language-js">const mem = JSON.stringify({ s: S, d: D, dr: retention });   // FSRS memory state
const ef  = 1.3 + (10 - D) / 5;                              // D=1 → ~3.1, D=10 → 1.3
return { type: 2, queue: 2, due: CRT_BACK + (dueDay - today), ivl,
         factor: Math.round(Math.min(3.1, Math.max(1.3, ef)) * 1000), data: mem };
</code></pre>
<p>Each note's GUID is derived from a hash of the word, so exporting the same words again updates the existing notes in Anki instead of creating duplicates.</p>
<p><strong>Reading Anki files</strong> uses the same code in reverse, plus a random-access ZIP reader (<code>Blob.slice</code>, so a deck with hundreds of MB of media only reads the bytes it needs). Recent Anki versions write <code>collection.anki21b</code>, which is the same SQLite database compressed with Zstandard. For that one case we vendor <code>fzstd</code> (MIT, pure JS, about 8.5 KB), loaded only when such a file appears. Imported fields are HTML, and we never put them into <code>innerHTML</code>: they're converted to plain text, and any <code>&lt;</code>/<code>&gt;</code> left after conversion is replaced with a lookalike character.</p>
<h2>4. One codebase: PWA and MV3 extension</h2>
<p>The extension is the web app. A build script rsyncs <code>web/</code> into <code>extension/app/</code> and then applies the Chrome Web Store rule that matters most: <strong>no remotely hosted code.</strong> Code paths that load SDKs from a CDN on the web are disabled inside the extension, the build deletes those URLs from the copied files, then greps for any surviving remote <code>import()</code>, <code>.src =</code> or <code>importScripts()</code> and fails if it finds one. Inline scripts, which MV3's CSP forbids, are stripped as well. Data such as JSON, audio and images may still come from the network. Code may not.</p>
<p>On the web, the service worker uses three strategies:</p>
<ul>
<li><strong>Content-addressed audio and static assets:</strong> cache-first. The file name never changes, so a cached copy is always correct.</li>
<li><strong>Everything else from our origin:</strong> network-first with a 2.5-second timeout that falls back to the cache.</li>
<li><strong>Navigation fallback only for navigations.</strong> An earlier version returned the cached <code>index.html</code> for any slow request, including JS files, which produced "MIME type text/html" errors. Now HTML is never served in place of a script.</li>
</ul>
<p><strong>Sync</strong> is optional. Firebase handles sign-in and nothing else; learning data lives in Cloudflare D1 behind a Pages Function. The client splits the card collection into shards and hashes each one, so a pull only returns shards whose hash differs and a push uploads only changed shards. Conflicts resolve per card by most recent activity.</p>
<h2>5. Lessons learned</h2>
<p><strong>We removed View Transitions.</strong> Same-document <code>document.startViewTransition()</code> made route changes look smooth until people pressed Back on long pages. Chrome captured the old page snapshot, our router scrolled to the top, and the result was misaligned, overlapping or blank regions during the transition. We replaced the whole block with a CSS fade on the content area, disabled under <code>prefers-reduced-motion</code>:</p>
<pre><code class="language-css">#view { animation: viewIn .18s ease-out; }   /* opacity only: no transform, */
@keyframes viewIn { from { opacity: 0; } }   /* so fixed dialogs keep their containing block */
@media (prefers-reduced-motion: reduce) { #view { animation: none; } }
</code></pre>
<p>Back navigation now renders cleanly, and a navigation test across Chromium, WebKit and Firefox on desktop and mobile viewports checks for exactly this kind of mis-paint.</p>
<p><strong>Budgets beat good intentions.</strong> Every "it's only 20 KB" made sense on its own, and the shell grew anyway. Once the size check failed the build, splitting data and lazy-loading strings became the default.</p>
<p><strong>Machine translation needs guardrails, not trust.</strong> Ambiguous English source strings were the biggest source of bad translations. "Resume" came out as <em>CV</em>, and "Look up" came out as <em>look upward</em>. Writing unambiguous English and hand-translating short labels fixed more than switching translation engines would have.</p>
<p><strong>File formats are friendlier than they look.</strong> The SQLite file format spec and the ZIP spec are both short and precise. A deliberately minimal, write-once implementation with a test that runs the real <code>sqlite3</code> was less work than integrating a general-purpose SQLite build, and it stays within the size budget.</p>
<p>If you want to try the review flow or the Anki export, the app is at <a href="https://lexkite.com/en/?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=launch">lexkite.com</a>. It's free and doesn't require an account.</p>
<p><em>IELTS, TOEIC and TOEFL are trademarks of their respective owners. Lexkite is an independent product and is not affiliated with or endorsed by them.</em></p>
<p><em>Originally published on <a href="https://dev.to/zulily_jewelry_vintage_92/building-a-15-language-fsrs-vocabulary-app-in-vanilla-js-and-exporting-real-anki-apkg-files-in-3fl7">DEV</a>.</em></p>
]]></content:encoded></item></channel></rss>