DEV Community

Cover image for Two weeks of building Bible School LMS in public: first contributor, bilingual content, real production bug
Vadym Arnaut
Vadym Arnaut

Posted on

Two weeks of building Bible School LMS in public: first contributor, bilingual content, real production bug

Two weeks ago I posted "Open Source Bible School LMS, we need your help" and asked for contributors.

Today the first community PR landed. Plus a stack of changes shipped that I think are worth sharing, both because some of them are genuinely interesting, and because it gives a concrete picture of where help would land.

Repo: github.com/ArVaViT/biblie-school (MIT, looking for stars and PRs)
Live: biblie-school-frontend.vercel.app

The first community PR

Kushal picked up a good first issue (a floating scroll-to-top button), opened a PR, took a code review without taking it personally, pushed clean follow-up commits, and got merged the same day. That sounds small. For a tiny open-source repo it is the most important kind of small.

If you are reading this and have never opened a PR on someone else's project, this is exactly the type of issue we keep around for that reason. There are more on the board.

What shipped: bilingual content (RU and EN)

The biggest piece of work in these two weeks was the translation pipeline. Goal: a teacher writes a course in their own language, students read it in theirs, no UI dropdown, no "main language", no humans needed in the loop.

How it works:

  1. Every teacher-authored field (course title, module, chapter, rich-text block, quiz, assignment, announcement, calendar event, cohort) is registered once in backend/app/services/translation/registry.py. The registry is the single source of truth for what gets translated and how. Adding a new translatable entity is one entry plus a Postgres CHECK constraint update.

  2. On publish (and on per-entity edits to a published course) a hook walks the registry, hashes the source text, and calls Gemini for any field whose hash changed. Result is cached in public.content_translations keyed by (entity_type, entity_id, field, locale).

  3. The student's Accept-Language header drives an overlay layer at read time. The catalog, the chapter view, the certificate, all of it returns the locale the user asked for, falling back to the source.

  4. A static CI guard introspects every FastAPI route. If a GET that returns a translatable schema is missing Accept-Language, or a POST/PUT/PATCH on a translatable entity does not reference one of the canonical translation hooks, the build fails. That sounds aggressive but it caught two real regressions during development.

The interesting part: Bible quotes do not go through the LLM

Letting an LLM "translate" scripture is a bad idea. Even the best models paraphrase. KJV and Synodal are public-domain canonical texts and students need to read the canonical wording, not a model's interpretation.

So the pipeline pre-substitutes around the LLM:

  1. The translator detects <blockquote> plus a parenthesised reference like (Acts 1:8) or (Деян. 20:28), in the inside-the-quote layout and the outside-the-quote layout.
  2. It compares the author's quoted text to the bundled canonical source-locale verse using SequenceMatcher. If similarity is at least 0.80, it is a real canonical quote and gets replaced with an ASCII marker like VERSE_a1b2c3d4e5f6g7h8 before the request goes to Gemini.
  3. After translation, the marker is replaced with the canonical target-locale verse from a 4.5 MB KJV (1769) JSON or a 6.1 MB Synodal (1876) JSON, both bundled.
  4. The reference itself, (Acts 1:8), is also rewritten in the target locale's conventional short form, (Деян. 1:8).

Below the 0.80 similarity threshold the substitution skips and the LLM handles the quote with a "leave verses untouched" prompt rule as a fallback.

There are 66 books in the canonical Protestant canon, each with a list of recognised aliases (including Synodal abbreviations like Матф. and Деян. that the first version of the parser quietly missed). All 66 are in regression tests.

What observability caught the day we plugged it in

Datadog RUM had been wired into the frontend for a while but I was not actively looking at it. The day I finally hooked up the API, the read endpoint immediately surfaced something useful: 10 errors across 4 sessions in 24 hours, all the same:

TypeError: Failed to fetch dynamically imported module:
    .../assets/ChapterView-DYS-mrkM.js
Enter fullscreen mode Exit fullscreen mode

This is the classic Vite SPA stale-chunk failure. After a deploy, every open tab is still holding the old index.html whose <script> tag references chunk hashes the new build no longer publishes. The next lazy-route navigation throws this error. The previous behaviour was to show a generic "Something went wrong" page with a manual "Refresh" button. Most users would close the tab and never come back.

Fix is a few lines in the ErrorBoundary: detect the chunk-load signature (Vite's, webpack's, and the named ChunkLoadError all have different messages), do a single window.location.reload(), guard against loops with a 60 second cooldown in sessionStorage. Reloading fetches the fresh index.html and the user is back where they were.

The point is not the bug. The point is that I would not have known about this without the telemetry. CI was green. Everything looked healthy. Real users were silently churning.

What else moved (compressed list)

  • Auth callback page is fully translated now. Russian users no longer see English during email confirmation or password reset.
  • Course detail page is fully translated, including the admin "manage course" flow that previously hid the enroll button.
  • OpenGraph and Twitter cards added so links to courses unfurl properly when pasted into Slack, Telegram, X, LinkedIn.
  • Backend favicon is now a real dark-variant of the brand glyph instead of an empty 204.
  • Internal cleanups: 30+ hardcoded English strings routed through i18n, repo stripped of editor-specific tooling references so it reads neutral about how a contributor authors code, security advisor warnings cleared.

Where contributors fit in

Same answer as two weeks ago, with sharper edges:

If you like... Look at...
React + TypeScript + Tailwind the issues tagged frontend and good first issue
Python + FastAPI + Pydantic the issues tagged backend
Accessibility prefers-reduced-motion is missing in a few animated bits, focus management in modals could be tighter
i18n adding a third locale would mean one new entry in the registry, one new _translation JSON, one new bundled Bible (or none if the language can fall back to a sibling). The pipeline is built to scale here.
Docs "I tried to set this up on macOS / Linux and these were the snags" stories help more than you would think
Testing Playwright E2E for the student happy path is on the roadmap and unstarted

The bar to contribute is not "do not break anything". The bar is "open a draft PR with a question, talk it through, push some commits, get reviewed". Kushal did exactly that in a few hours.

One line again

Free LMS, small scale, real classrooms, with a translation pipeline that does not paraphrase scripture. Star the repo, pick an issue, ping me anywhere.

github.com/ArVaViT/biblie-school

Thanks for reading. See you in the issues tab.

Top comments (0)