I built a 23-language learning app with zero dependencies and no build step. Here's what offline-first really takes
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:
- 23 courses with 442 lessons and 3,563 vocabulary items
- 23 "book" dictionaries with 168 headwords each (3,864 entries), 2,209 of them with a full definition and example sentence
- 161 tests, run with nothing but
node --test
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:
/api/*always goes to the network and is never cached. That's the optional sync backend.- Navigations are network-first and fall back to the cached
index.html. If even that is missing (first visit made offline), the user gets a small readable offline page, not the browser's error screen. - Static assets are cache-first. They never fall back to the HTML shell, because a module request answered with HTML is a hard parse error that takes the whole app down.
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
- Spaced repetition is an SM-2-lite in
js/core/state.js. It has four grades (again/hard/good/easy), starts ease at 2.5 with a floor of 1.3, and puts dictionary words you've starred into the same rotation as lesson words. - Exam certificates print the same in light and dark themes. The first real PDF export printed the "Install app" button on every page. In dark mode, with "background graphics" ticked, half the text was invisible. Print colours are now pinned to ink-on-white in
@media print. An interaction test emulates print media in both themes, asserts the computed colours match, and checks that the PDF is exactly one page. - CSP is
script-src 'self'plus one SHA-256 hash for the single inline framebuster script. A test fails if a second inline script ever appears. - Randomness for shuffles and exam picks uses
crypto.getRandomValueswith rejection sampling, neverMath.random().
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)
- The UI is available in Indonesian, English and Spanish, and word meanings exist only in those three. The README, design log and commit messages are mostly in Indonesian.
- Content depth is uneven. English and Korean have 32 lessons each; the other 21 languages have 18. The dictionaries, by contrast, are identical in size.
- Pronunciation quality depends on the voices your device has.
- Accounts in static-hosting mode live in IndexedDB (PBKDF2, 600,000 iterations). That keeps profiles separate on a shared device, but it doesn't protect anything from someone with physical access to the device.
- No usage numbers to share. It's a side project, and I haven't added analytics.
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
- Live demo: https://xtheoputra.github.io/jago-bahasa/
- Source (MIT): https://github.com/xtheoputra/jago-bahasa
- xTheo Labs: https://xtheo-labs.vercel.app
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 |