I built a 23-language learning app with zero dependencies and no build step. Here's what offline-first really takes

By Harasta Rahman Tri Putra (xTheoPutra) ·

I built a 23-language learning app with zero dependencies and no build step. Here's what offline-first really takes

Jago Bahasa ("good at languages" in Indonesian) is a free, open-source PWA for learning 23 languages, from Korean and Arabic to Swahili and Ukrainian. It is plain HTML, CSS and ES modules. There is no framework, no bundler, no node_modules, and package.json has no dependencies key at all. You can open the live demo, load it once, switch to airplane mode, and keep studying.

What it contains today, recounted from the repo rather than copied from the README:

I didn't write all of that by hand. I built it over about four months with Claude Code as a pair programmer, and the full design log is in docs/SESSION-LOG.md. The interesting part, for me, was not the features but what it took to keep an offline-first app honest.

The service worker is the whole product

If your app has to work offline, the precache list is your release. sw.js holds a VERSION string (currently jb-v3.5.2) and an ASSETS array of 84 entries: the shell, every ES module, all 23 course chunks and all 23 dictionary volumes. Install caches all of it. Activate deletes every cache whose name doesn't match the current version. Then the fetch handler applies three rules:

On the page side, js/pwa.js shows an "update available" toast. It reloads on controllerchange only if the page already had a controller, so first-time visitors don't get a pointless reload flash.

The honest limitation: cache-first means returning users keep old code until I bump VERSION. That bump is manual. Nothing in the test suite forces it.

Splitting the catalogue into per-language chunks that load on demand made startup much lighter. All chunks are still precached, so lazy loading never breaks offline use. The catch is that every new file has to appear in ASSETS. So tests/assets.test.mjs parses sw.js and fails if any front-end module is missing from the list, or if the list points at a file that doesn't exist.

The bug that 156 green tests couldn't see

For 42 days, the live site served every dictionary but zero lessons.

The root .gitignore had a line data/, meant for the backend's runtime JSON store. Without a leading slash, git matches a data directory at any depth, including js/data/, where all 23 course files live. The files existed on my disk, so the tests passed and git status was clean. They just never reached GitHub, and GitHub Pages returned 404 for every lesson.

The first CI run caught it, because a CI runner clones from git rather than reading my disk. The fix was one character (/data/), plus a test in tests/assets.test.mjs that compares the precache list against what git actually tracks (excerpt):

test("every precached asset is tracked by git, not merely present on disk", (t) => {
  let tracked;
  try {
    tracked = new Set(
      execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8" })
        .split("\0")
        .filter(Boolean)
        .map((p) => `./${p}`)
    );
  } catch {
    return t.skip("not a git checkout — nothing to compare the precache list against");
  }
  // …
  const unshipped = [...precached].filter((p) => p !== "./" && !tracked.has(p));
  assert.deepEqual(
    unshipped,
    [],
    `precached but never committed — the deployed site will 404 on these: ${unshipped.join(", ")}`
  );
});

Lesson learned: being on disk doesn't mean you shipped it. Only git decides what ships.

A dictionary should be browsed by its own alphabet

A Korean dictionary isn't sorted A–Z, and neither is a Thai one. js/lexicon.js assigns each headword to a letter tab using the rules a printed dictionary in that script would use:

// js/lexicon.js (excerpt)
export function letterOf(lang, w, r) {
  const src = BY_READING.has(lang) ? r || w : w;
  const chars = Array.from(String(src).trim());
  let ch = chars[0] || "";
  if (lang === "th" && THAI_LEAD_VOWELS.includes(ch)) ch = chars[1] || ch;
  if (ARABIC_BASE[ch]) ch = ARABIC_BASE[ch];
  if (!ch) return "#";
  const code = ch.codePointAt(0);
  if (SCRIPT_OF[lang] === "han" && code >= 0xac00 && code <= 0xd7a3) {
    return HANGUL_LEAD[Math.floor((code - 0xac00) / 588)] || "#";
  }
  // … then alphabet lookup and diacritic folding for Latin, Cyrillic, Greek

Hangul syllables are filed by their leading jamo: each initial consonant covers a block of 588 code points, so plain division finds it. Thai skips the four vowel signs written before their consonant. Arabic files hamza and final forms under the base letter (أ إ آ → ا, ة → ه). Japanese and Chinese are indexed by romaji and pinyin. Latin letters that NFD normalization can't decompose (Ł, Ø, ß) get an explicit map. A test checks that every headword lands in its own letter and that the letter blocks never interleave.

Refusing to fake a voice

Pronunciation uses the browser's built-in text-to-speech. On the stock Windows Chrome I tested, 10 of the 23 languages had no installed voice. The old behaviour handed Arabic text to whatever voice existed, so learners heard English phonetics or silence, and couldn't tell either from a bug. Now the app checks first:

// js/core/ui.js (excerpt)
const base = (lang) => String(lang || "en").toLowerCase().split("-")[0];

export function hasVoice(lang) {
  if (!("speechSynthesis" in window)) return false;
  const list = voices();
  if (!list.length) return true;
  const b = base(lang);
  return list.some((v) => v.lang && base(v.lang) === b);
}

Buttons that can't speak are marked and explained, and the audio-only modes refuse to start. The split("-") is there because of a bug the tests caught: the first version used slice(0, 2), and Tagalog's tag is fil-PH. The first two letters of that are fi, which is Finnish. One test in tests/tts.test.mjs is literally named "Tagalog is fil, and the first two letters of that are Finnish".

Smaller things that still took real work

Testing without a framework

npm test is just node --test --test-concurrency=1. The 161 tests cover content shape, i18n key parity, SRS scheduling, exam fairness, the optional backend on a random port, and two browser suites. Those browser suites drive real Chrome over the DevTools Protocol using Node's built-in WebSocket, so they need no Puppeteer or Playwright. They skip themselves if Chrome is missing. On my machine the full run takes about 99 seconds. CI runs it on Node 20 and 22.

One more lesson from CI: the final-exam test used to try only 10 random rounds and passed for weeks while the generator was lopsided (one stage got 9 questions, another 6). At 200 rounds it fails immediately on the old code. A test that samples randomly a few times isn't a guard. It's a lottery.

What it isn't (yet)

If you're thinking about building an offline-first app without a toolchain, my takeaway is that the build step you skip comes back as discipline. Your precache list, your .gitignore, and your version bump become the release process, so test them like code.

Links


Sources (claim → where to verify)

Repo checked out at commit e8389fa (2026-10-19). Commands are run from the repo root.

Claim Source / command
23 languages / courses js/data.js (COURSES, 23 entries); ls js/data → 23 files
442 lessons, 3,563 vocabulary items Load every js/data/<id>.js and count lessons and items; matches the sum of n in js/data.js
en and ko have 32 lessons, the other 21 have 18 Same count, per course
23 dictionaries × 168 = 3,864 entries js/lexicon.js (LEXICONS, all n: 168); count the export default arrays in js/lexicon/*.js
2,209 entries with definition and example Count entries with both d and ex in js/lexicon/*.js
Band shape A1 40 · A2 40 · B1 28 · B2 28 · C1 16 · C2 16 Per-language CEFR count (totals 920/920/644/644/368/368)
Zero runtime dependencies package.json has no dependencies; grep -rhoE "(from|require\()\s*['\"][^'\"./]" server tests js shows only Node built-ins
No build step No bundler config; index.html loads js/app.js as type="module" directly
SW VERSION jb-v3.5.2, 84 precached entries sw.js lines 5–98; awk '/const ASSETS = \[/,/^\];/' sw.js | grep -c '"\./' → 84
Old caches deleted on activate; /api network-only; navigations network-first; assets cache-first with no HTML fallback sw.js activate and fetch handlers
Update toast, reload only when page already had a controller js/pwa.js
Precache list ↔ modules and existing files test tests/assets.test.mjs: "every front-end ES module is precached", "the precache list has no dead entries"
.gitignore data/ swallowed js/data/ for 42 days Commit 3f09801 (2026-07-23, chunk split) → git log --diff-filter=A -- js/data/es.js → c01baae (2026-09-03); .gitignore comment; docs/SESSION-LOG.md lines 14–42
Git-tracked precache test tests/assets.test.mjs, test at line 161
Per-script A–Z indexing (Hangul ÷588, Thai leading vowels, Arabic base letters, romaji/pinyin, Ł/Ø/ß) js/lexicon.js lines 128–194 (letterOf, ARABIC_BASE, THAI_LEAD_VOWELS, LATIN_BASE)
Letter-block test tests/lexicon.test.mjs (README test table describes it)
hasVoice splits at the hyphen; Tagalog/Finnish bug js/core/ui.js (base, hasVoice); tests/tts.test.mjs "Tagalog is fil, and the first two letters of that are Finnish"; docs/SESSION-LOG.md v3.4 section
10 of 23 without a voice on stock Windows Chrome Observation in docs/SESSION-LOG.md v3.4 section and README feature table (machine-dependent, not reproducible by command)
Audio-only modes refuse to run without a voice js/views/practice.js (hasVoice checks at ~line 509 and 1491); interact test "a language this device cannot pronounce says so…"
SM-2-lite, ease 2.5, floor 1.3, 4 grades, dictionary words in rotation js/core/state.js srsGrade, dictCardKeys, srsPool
Certificate prints identically in light/dark, one page, no install button css/styles.css @media print (line 1129); tests/interact.test.mjs "the certificate prints the same sheet whether the screen is dark or light"
CSP script-src 'self' 'sha256-4zd2…' matches the framebuster index.html line 6; printf '%s' '<script body>' | openssl dgst -sha256 -binary | base64 → 4zd2smDdcAUaJ7gzkxrkyD/owwoLFxcWP8NtZjfyz54=
Only one inline script allowed tests/assets.test.mjs "index.html loads the app as a module and keeps its CSP"
Crypto randomness, rejection sampling js/core/random.js (randInt, shuffle)
161 tests, 0 skipped, ~99 s npm test on Node v24.21.0, Windows 11 with Chrome: tests 161 · pass 161 · skipped 0 · duration_ms 98564
Browser tests via CDP with Node's built-in WebSocket tests/interact.test.mjs header comment and imports (only node: modules)
CI on Node 20 and 22 .github/workflows/uji.yml matrix
Final-exam imbalance 9/6/8; 10 → 200 rounds Commit aa07e8e; docs/SESSION-LOG.md lines 44–60
UI in id/en/es, auto-selected from browser language js/i18n.js (langs, init() uses navigator.language, default id)
Local accounts: PBKDF2-SHA256, 600,000 iterations, IndexedDB js/auth/crypto.js line 14 (ITERATIONS = 600_000); js/auth/db.js
Built with Claude Code docs/SESSION-LOG.md line 3
About four months of development git log --reverse first commit 2026-06-24; last commit 2026-10-19
MIT license package.json "license": "MIT"; README footer