<?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: Vadym Arnaut</title>
    <description>The latest articles on DEV Community by Vadym Arnaut (@arvavit).</description>
    <link>https://dev.to/arvavit</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896990%2Ff229c67f-46a1-4ecf-b3f5-71e2dd14f1bc.jpg</url>
      <title>DEV Community: Vadym Arnaut</title>
      <link>https://dev.to/arvavit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arvavit"/>
    <language>en</language>
    <item>
      <title>4 footguns integrating YouVersion's new platform API — and a clean Verse of the Day</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Fri, 15 May 2026 18:39:44 +0000</pubDate>
      <link>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</link>
      <guid>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; YouVersion opened its API in April 2026. We wired it into our open-source Bible LMS as a Verse of the Day card on the dashboard. Four small things in their API surface cost us time. Writing them up so you don't repeat them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Equip is an open-source LMS for Bible schools (FastAPI + React + Supabase, MIT). A dashboard verse-of-the-day card is one of those quietly load-bearing features for a faith-shaped product — it gives the page a pastoral anchor on the right while keeping the actionable column primary, and creates a daily-return habit without screaming for engagement.&lt;/p&gt;

&lt;p&gt;YouVersion just opened &lt;strong&gt;YouVersion Platform&lt;/strong&gt;: 1,474 Bibles, 1,283 languages, REST API plus SDKs (Swift, Kotlin, React, React Native). Free for non-commercial use. So we plugged it in.&lt;/p&gt;

&lt;p&gt;The endpoints are simple. The friction is in the small print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun #1: &lt;code&gt;/v1/bibles&lt;/code&gt; returns 422 without &lt;code&gt;language_ranges[]&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First instinct after &lt;code&gt;pip install httpx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong. Will not work.
&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Unprocessable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Entity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"missing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"loc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"language_ranges[]"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Field required"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The listing endpoint requires at least one ISO 639-3 language filter. We found that out the way everyone does — by reading the 422 body. Correct form:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language_ranges[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eng&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# eng, rus, ukr, etc.
&lt;/span&gt;    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&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;If you're calling from a JS client and reach for &lt;code&gt;URLSearchParams&lt;/code&gt;, the brackets matter — &lt;code&gt;language_ranges[]&lt;/code&gt; with the literal brackets works, &lt;code&gt;language_ranges&lt;/code&gt; without them does not.&lt;/p&gt;

&lt;p&gt;Side note while you're here: the auth header is &lt;code&gt;X-YVP-App-Key&lt;/code&gt;, not &lt;code&gt;Authorization: Bearer&lt;/code&gt;. The 401 you get from forgetting is generic enough that you'll sit there comparing your key character-by-character before going back to the docs. Pin the header name in the integration's docstring on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #2: &lt;code&gt;all_available=true&lt;/code&gt; reveals the real catalog
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;language_ranges[]=eng,rus,ukr&lt;/code&gt; we got &lt;strong&gt;11 Bibles back&lt;/strong&gt; — all English (ASV, BSB, Geneva, CPDV, etc.). Zero Russian. Zero Ukrainian.&lt;/p&gt;

&lt;p&gt;The marketing number is 1,474 translations. Where are they?&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language_ranges[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all_available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KEY&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;That returns 5 Russian Bibles: NRT (Новый Русский Перевод — the modern Russian we needed), three CARS variants, and CSLAV (church Slavonic). For Ukrainian: just UKRK 1905 — pre-revolutionary, hard for modern students. No modern Огієнко or UBIO in the platform yet.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;the listing endpoint without &lt;code&gt;all_available&lt;/code&gt; shows what is enabled for your app key&lt;/strong&gt;, not what exists in the platform. Fetching a passage via &lt;code&gt;/bibles/{id}/passages/{ref}&lt;/code&gt; works for the broader catalog regardless of listing visibility — at least for non-commercial use as of this writing.&lt;/p&gt;

&lt;p&gt;Worth two minutes verifying for your own keys before you build a UI that says "no Russian translations available." We almost did.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #3: &lt;code&gt;content&lt;/code&gt; is HTML, even when you didn't ask
&lt;/h2&gt;

&lt;p&gt;The passage endpoint is the cleanest part of the API. You hand it a Bible ID and a USFM ref:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/bibles/&lt;/span&gt;&lt;span class="mi"&gt;143&lt;/span&gt;&lt;span class="err"&gt;/passages/JHN.&lt;/span&gt;&lt;span class="mf"&gt;3.16&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JHN.3.16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Ведь Бог так полюбил этот мир...&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"От Иоанна 3:16"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things to notice. First, &lt;code&gt;reference&lt;/code&gt; is &lt;strong&gt;localized to the Bible's language&lt;/strong&gt; — "От Иоанна 3:16" for NRT, "John 3:16" for BSB, automatically. That is free i18n you would otherwise build yourself.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;content&lt;/code&gt; is HTML. Usually a thin &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; wrapper, sometimes with &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; markers, occasionally a chunk of whitespace bleeding from the source USFM. If you're rendering scripture in a plain-prose sidebar card, you have to strip:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_strip_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;[^&amp;gt;]+&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Some responses pack newlines inside &amp;lt;p&amp;gt; from the source markup —
&lt;/span&gt;    &lt;span class="c1"&gt;# collapse whitespace so the card renders as a single tidy line.
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;YouVersion never injects user-supplied markup into &lt;code&gt;content&lt;/code&gt; (it is their licensed text — they control the source), so a regex strip is safe here. Do not copy this pattern verbatim to places where the content origin is untrusted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #4: their VOTD endpoint exists, but you cannot pick the verses
&lt;/h2&gt;

&lt;p&gt;YouVersion ships &lt;code&gt;/v1/verse_of_the_days/{day}&lt;/code&gt; that returns &lt;em&gt;their&lt;/em&gt; curated verse for that day of year:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/verse_of_the_days/&lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"passage_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ISA.12.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Useful if you want to mirror the YouVersion app behavior exactly. But it is their selection — you cannot pass a tag, theme, or audience filter. For an LMS aimed at Bible-school students we wanted curated passages that bias toward &lt;strong&gt;doctrinally-neutral, evergreen, ecumenical&lt;/strong&gt; scripture: no denomination-specific proof texts, nothing that translates badly into Russian or Ukrainian.&lt;/p&gt;

&lt;p&gt;So we rolled our own list — 250 references covering salvation, hope, comfort, love, faith, wisdom, prayer, and perseverance. Grouped by canon section: Gospels, Acts, Pauline epistles, Hebrews and General epistles, Revelation, Psalms, Proverbs, Wisdom and Prophets, Torah and Historical books, Job and Minor prophets.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape that worked: deterministic by day, cached by day
&lt;/h2&gt;

&lt;p&gt;Selection is a one-liner:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_pick_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Everyone, everywhere, sees the same verse on a given UTC date.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toordinal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;toordinal()&lt;/code&gt; gives a globally monotonic integer. Modulo the catalog size, you get a cycle that takes about eight months to repeat — long enough that no student sees the same verse twice in a school term. No randomness, no per-user state, no cache invalidation drama. The verse on a given date is the same in every browser on every device.&lt;/p&gt;

&lt;p&gt;Cache is a process-local dict keyed on &lt;code&gt;(date_iso, locale)&lt;/code&gt;. Evict any entry whose date is not today on every write — the cache never holds more than &lt;code&gt;len(supported_locales)&lt;/code&gt; entries. We support &lt;code&gt;en&lt;/code&gt; and &lt;code&gt;ru&lt;/code&gt;, so at most two entries deep:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;_CACHE_LOCK&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&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="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;date_iso&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two YouVersion calls per Python process per day. If the API key is unset (CI, fresh local dev), the service raises a typed exception and the route returns 404. The frontend treats 404 as "hide the card silently."&lt;/p&gt;

&lt;p&gt;Never block the dashboard on a third party. Same playbook as every other optional widget — but worth restating, because Bible-school students will not forgive a broken homepage on a Sunday.&lt;/p&gt;
&lt;h2&gt;
  
  
  A few things to weigh before you adopt this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-commercial lock-in is real.&lt;/strong&gt; You agree at app registration. Future paywalls, ads, or paid tiers revoke API access. For an open-source LMS this is fine. For a commercial product, model around it on day one — your &lt;code&gt;BibleProvider&lt;/code&gt; should be an adapter, not a hard import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Russian coverage is solid, Ukrainian is sparse.&lt;/strong&gt; Modern Russian is there (NRT). Ukrainian audiences still need a fallback layer for modern translations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SDKs ship UI components&lt;/strong&gt;, not just data clients. If you're using their React SDK you may not need most of the integration code above. We chose the REST API because Verse of the Day in our app is server-cached and renders in a slot we already control.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What I'd ask the room
&lt;/h2&gt;

&lt;p&gt;If you have integrated YouVersion Platform yourself — how are you handling the &lt;code&gt;locale → bible_id&lt;/code&gt; mapping when there are multiple translations in the same language? We pick one per locale (&lt;code&gt;en&lt;/code&gt; → BSB, &lt;code&gt;ru&lt;/code&gt; → NRT) because Verse of the Day is a one-card, one-rendering surface. For a reading-plan flow where users want to switch translations on the fly the design space is wider — curious what others have done.&lt;/p&gt;

&lt;p&gt;For anyone building Christian software in 2026 — does the non-commercial lock-in change how you architect, or do you accept it and never plan to monetize? Genuinely curious whether anyone has built around the constraint.&lt;/p&gt;

&lt;p&gt;And for the YouVersion Platform team if you read this: an &lt;code&gt;Accept: text/plain&lt;/code&gt; toggle on &lt;code&gt;/passages&lt;/code&gt; would let consumers skip the strip-HTML dance. Probably trivial on your side, ergonomic win on ours.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>fastapi</category>
      <category>react</category>
      <category>api</category>
    </item>
    <item>
      <title>Two days lost to PGRST116: when Supabase RLS hides a successful write</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 19:36:01 +0000</pubDate>
      <link>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</link>
      <guid>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Our Supabase upsert wrote the row. The chained &lt;code&gt;.select().single()&lt;/code&gt; returned PGRST116. The wrapper read that as a failed write. The frontend retried. The retry was wrong. Two days to find why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been running an LMS on Supabase for the past several months — auth, RLS on every table, FastAPI talking to Postgres. The bug below cost us two days last quarter, and the fix changed how we read PGRST116 in our wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting
&lt;/h2&gt;

&lt;p&gt;We have a &lt;code&gt;quiz_attempts&lt;/code&gt; table. Each attempt gets created on quiz start, updated as the student progresses. The update is an upsert because a retry of the same &lt;code&gt;attempt_id&lt;/code&gt; should patch the existing row, not insert a duplicate.&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quiz_attempts&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;upsert&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="nx"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quiz_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt_count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;.select().single()&lt;/code&gt; chain returns the upserted row so we can show the student their new state.&lt;/p&gt;

&lt;p&gt;RLS policies, simplified:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students write their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students read their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;is_visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The SELECT policy carries an extra &lt;code&gt;is_visible&lt;/code&gt; check that the write policy doesn't. That asymmetry is the seam the bug walks through.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A trigger on &lt;code&gt;quiz_attempts&lt;/code&gt; flips &lt;code&gt;is_visible&lt;/code&gt; to false under a corner case (timing relative to a parallel write from an admin tool — the specifics aren't the point). The student's upsert committed: their fields were written, the row is theirs.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;.select().single()&lt;/code&gt; runs. The SELECT policy applies. &lt;code&gt;is_visible = false&lt;/code&gt;. PostgREST returns:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PGRST116"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Results contain 0 rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JSON object requested, multiple (or no) rows returned"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our runtime wrapper auto-threw on any &lt;code&gt;.error&lt;/code&gt;. To the upstream code, this looked identical to a failed upsert. The frontend retried with the same payload. The retry hit the same trigger. The user state diverged from the DB state. Etc.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this was hard
&lt;/h2&gt;

&lt;p&gt;PGRST116 has two completely different meanings when it comes after a mutation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The upsert truly failed&lt;/strong&gt; — constraint violation, missing required field, RLS denied the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upsert succeeded&lt;/strong&gt; — RLS just hid the returned row from the caller.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The wrapper conflated them. The PostgrestError code is identical. The HTTP status is identical (406). The only difference lives in the database, which the client can't see.&lt;/p&gt;

&lt;p&gt;Two days of logs because we kept treating "no row returned" as "no row written."&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't auto-throw on PGRST116 from a &lt;code&gt;.select().single()&lt;/code&gt; chained off a mutation.&lt;/strong&gt; Treat it as ambiguous and route to a separate branch:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeUpsertReturning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PGRST116&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="c1"&gt;// The mutation may have succeeded; we just can't read the row.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&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="na"&gt;error&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="na"&gt;hidden&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;&lt;strong&gt;2. Service-role verification.&lt;/strong&gt; When the wrapper returns &lt;code&gt;hidden: true&lt;/code&gt;, the backend does a service-role read by &lt;code&gt;id&lt;/code&gt; to confirm whether the row actually exists. Yes → success-without-readback (treat as written). No → real failure, propagate. The client never branches on this directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Observability tying client errors to DB state.&lt;/strong&gt; Every PGRST116 from the wrapper emits a structured event with the query shape and request id. Server-side, we log the same id with the actual row state visible to service role. Correlating the two would have surfaced the mismatch on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past-us
&lt;/h2&gt;

&lt;p&gt;PGRST116 is not a write error. It's a &lt;strong&gt;visibility&lt;/strong&gt; error.&lt;/p&gt;

&lt;p&gt;Your wrapper, your error handler, your retry logic — each should know which one it's seeing. If your stack can't tell the difference, you're going to retry successful writes. The kind of bug that produces is the kind where the symptom and the cause are twelve layers apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you separate "write failed" from "write succeeded but I can't read it" in your Supabase code? What does the wrapper look like?&lt;/li&gt;
&lt;li&gt;Has anyone built a Postgres-side audit trigger that captures "row was written but RLS hid it from the writer"? Curious about the shape.&lt;/li&gt;
&lt;li&gt;For service-role verification — anything safer than &lt;code&gt;select(*).eq('id', id)&lt;/code&gt; you've used?&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;The project that runs this stack is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The 3 i18n mistakes every open-source LMS makes</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 03:21:32 +0000</pubDate>
      <link>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</link>
      <guid>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Every open-source LMS treats internationalization as one problem. It's three. UI strings, user-generated content, and canonical artifacts each need a different mechanism. Most codebases collapse them into one — that's the bug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been building an open-source Bible school LMS for about 5 months. It runs in Russian and English (Ukrainian coming), with auto-translated user content and canonical-text preservation for scripture quotes. Building this forced me to look at how Moodle, Open edX, Canvas LMS, and Chamilo handle the same problem.&lt;/p&gt;

&lt;p&gt;The pattern is the same in all of them — and was the same in ours when we started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake #1: User-generated content treated like UI strings
&lt;/h2&gt;

&lt;p&gt;Every LMS has solid gettext-style infrastructure for UI strings. "Sign in", "Course catalog", "Submit assignment" — these live in &lt;code&gt;.po&lt;/code&gt; files (Moodle), Transifex (Open edX), &lt;code&gt;i18n-js&lt;/code&gt; (Canvas), YAML (Rails-based). A translator translates the file once. Done.&lt;/p&gt;

&lt;p&gt;User-generated content is a different problem entirely. When a teacher authors a course in Russian, the title — &lt;em&gt;"Введение в Послание к Римлянам"&lt;/em&gt; — is a row in &lt;code&gt;courses.title&lt;/code&gt;. An English-speaking student opens the catalog and sees Cyrillic. &lt;strong&gt;The UI is translated. The content isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;UI strings&lt;/th&gt;
&lt;th&gt;User-generated content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;"Sign in", "Submit"&lt;/td&gt;
&lt;td&gt;Course title, lesson body, quiz question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;Fixed catalog of values&lt;/td&gt;
&lt;td&gt;Unbounded user input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;gettext / Transifex / YAML&lt;/td&gt;
&lt;td&gt;Runtime translation (Google, DeepL, Gemini)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;Build / release time&lt;/td&gt;
&lt;td&gt;Runtime (lazy or eager)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Locale file&lt;/td&gt;
&lt;td&gt;Separate cache table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common bug&lt;/td&gt;
&lt;td&gt;None major&lt;/td&gt;
&lt;td&gt;Treated like UI strings → only one language ever shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Moodle's workaround is the multilang filter — you wrap content in &lt;code&gt;&amp;lt;span lang="ru"&amp;gt;…&amp;lt;/span&amp;gt;&amp;lt;span lang="en"&amp;gt;…&amp;lt;/span&amp;gt;&lt;/code&gt; and a filter shows the matching one. This is a 2008 solution. It puts the entire burden on the teacher: they must author every piece of content twice, in every language. Most don't, and the platform falls back to "show whatever the teacher wrote first."&lt;/p&gt;

&lt;p&gt;The shape of the right answer is a separate translation cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;content_translations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;entity_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'course', 'lesson', 'quiz_question'&lt;/span&gt;
  &lt;span class="n"&gt;entity_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'title', 'description', 'body'&lt;/span&gt;
  &lt;span class="n"&gt;locale&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'ru', 'en', 'uk', 'es'&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'human' | 'machine' | 'canonical'&lt;/span&gt;
  &lt;span class="n"&gt;cached_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locale&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;Teacher authors in one language. A translation worker fills in the others lazily (first request) or eagerly (on publish). The course-detail endpoint joins on &lt;code&gt;(entity_type, entity_id, field, viewer_locale)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architectural decision: &lt;strong&gt;content is not a UI string and cannot live in the same system.&lt;/strong&gt; If your LMS uses gettext for "Submit assignment" and the same mechanism for course titles, that's the bug.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #2: No accommodation for length variance
&lt;/h2&gt;

&lt;p&gt;English is dense. Russian runs ~25–30% longer for the same meaning. German is comparable. Finnish is worse. Arabic is shorter — and right-to-left, its own category.&lt;/p&gt;

&lt;p&gt;Most LMS UIs are designed in English. Buttons that fit "Save" don't fit "Сохранить". Nav tabs that fit "Courses" wrap to two lines for "Курсы и обучение". Mobile breaks first.&lt;/p&gt;

&lt;p&gt;Part of the fix is CSS:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Reserve space against the longer-language baseline */&lt;/span&gt;
&lt;span class="nc"&gt;.action-button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ellipsis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Tabular content: don't let translation reflow the grid */&lt;/span&gt;
&lt;span class="nc"&gt;.lesson-list-cell&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* fits 2-line Russian titles without shift */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But the real fix is &lt;strong&gt;testing every screen in your longest-content language&lt;/strong&gt;, not just English. Most teams hire designers who only see the English mock and don't realize their "Continue" button is broken in Russian until a user reports it.&lt;/p&gt;

&lt;p&gt;What helped us: a Storybook locale switcher defaulting to Russian (not English), and a Playwright snapshot suite that screenshots both locales. The first commit that breaks Russian layout is caught in CI, not by a user.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #3: Canonical content forced through translation
&lt;/h2&gt;

&lt;p&gt;This is the bug that motivated our entire content-translation rewrite.&lt;/p&gt;

&lt;p&gt;A teacher in Russian writes: &lt;em&gt;"Послание к Римлянам 8:28 говорит, что все содействует ко благу"&lt;/em&gt;. The course gets auto-translated to English. Naively you send the whole string to Google Translate or DeepL, and you get back: &lt;em&gt;"Romans 8:28 says that everything works together for good."&lt;/em&gt; That's almost a real Bible verse — but it isn't quoting any actual translation. It's a translation of a translation.&lt;/p&gt;

&lt;p&gt;You don't want that. You want the actual KJV (or NIV, or ESV) text of Romans 8:28 spliced in.&lt;/p&gt;

&lt;p&gt;Same problem exists everywhere there's canonical content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Programming courses with code samples (don't translate the variables!)&lt;/li&gt;
&lt;li&gt;Math curricula with formulas&lt;/li&gt;
&lt;li&gt;Legal courses citing statutes&lt;/li&gt;
&lt;li&gt;Medical courses citing clinical-trial registries&lt;/li&gt;
&lt;li&gt;Literature courses with original-language quotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most LMSes don't separate "translatable prose" from "canonical artifact" — so when they auto-translate, the canonical content gets mangled or invented.&lt;/p&gt;

&lt;p&gt;Our pattern: parse the source for canonical references, replace each with a placeholder token, translate the surrounding prose, then substitute the canonical text back from a separate lookup.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;translate_with_canonical_preservation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Find canonical references in either language
&lt;/span&gt;    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_bible_refs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# [{"raw": "Послание к Римлянам 8:28", "book": "ROM", "chapter": 8, "verses": [28]}]
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Replace each with a unique placeholder
&lt;/span&gt;    &lt;span class="n"&gt;placeholders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⟦CANON_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;⟧&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;token&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="c1"&gt;# 3. Translate the token-bearing string
&lt;/span&gt;    &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Substitute the canonical text back, looked up in the target translation
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;canonical_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup_canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# KJV for en, Synodal for ru
&lt;/span&gt;        &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  How &lt;code&gt;extract_bible_refs&lt;/code&gt; handles the cross-language book-name matrix
&lt;/h3&gt;

&lt;p&gt;Detection is a regex over a normalized book-name dictionary. Each canonical book has an entry like:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BOOKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ROM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Romans&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rom.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ru&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Послание к Римлянам&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Римлянам&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Послання до Римлян&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Римлян&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# ... 65 more books
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The regex is built per request as &lt;code&gt;(book_alias_1|book_alias_2|...)\s+(\d+):(\d+)(?:[-–](\d+))?&lt;/code&gt; so it accepts: &lt;code&gt;Romans 8:28&lt;/code&gt;, &lt;code&gt;Послание к Римлянам 8:28&lt;/code&gt;, &lt;code&gt;Рим 8:28&lt;/code&gt;, &lt;code&gt;Рим. 8:28&lt;/code&gt;, &lt;code&gt;Romans 8:28-29&lt;/code&gt;, &lt;code&gt;Romans 8:28a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sundry edge cases: chapter-only refs (&lt;code&gt;Romans 8&lt;/code&gt; — whole-chapter), letter suffixes (&lt;code&gt;8:28a&lt;/code&gt; — first half of verse), em-dash vs hyphen, non-breaking spaces. Each lives in unit tests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lookup_canonical&lt;/code&gt; pulls from a canonical text table keyed by &lt;code&gt;(book, chapter, verse_start, verse_end, translation)&lt;/code&gt;. Cache the final translated string keyed by &lt;code&gt;(entity_id, field, locale)&lt;/code&gt; per Mistake #1.&lt;/p&gt;

&lt;p&gt;This is ~400 lines we wouldn't have written if any of the LMSes we looked at had solved this for us.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;p&gt;These are the three patterns I keep seeing. They're not the only ones (cache invalidation on edits, RTL layout, Slavic plural forms — separate posts), but they're the ones every general-purpose LMS skips.&lt;/p&gt;

&lt;p&gt;If you've shipped i18n in an LMS, education platform, or any content product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do you separate UI strings from content?&lt;/strong&gt; What's your storage shape?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you handle length variance?&lt;/strong&gt; Do you test the long-language layout in CI?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you have canonical content that mustn't be translated?&lt;/strong&gt; Code samples, equations, citations? How are you handling it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious where teams have landed. The patterns above are our current best, not our last word.&lt;/p&gt;



&lt;p&gt;The project that drove all of this is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>i18n</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I wrote 'these fields are translatable' in five different files. Then I stopped.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 15:33:29 +0000</pubDate>
      <link>https://dev.to/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7</link>
      <guid>https://dev.to/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7</guid>
      <description>&lt;h2&gt;
  
  
  The same fact, written in five places
&lt;/h2&gt;

&lt;p&gt;I'm building an LMS where teachers write courses in one language and students read them in another. So content i18n. Standard problem.&lt;/p&gt;

&lt;p&gt;What surprised me was how many places "this field gets translated" had to live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;migrations&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;        &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="k"&gt;IN&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;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;schemas&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;          &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"course"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;           &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[...]]&lt;/span&gt;
&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"course"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;announcements&lt;/span&gt;   &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;reconcile_entity&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Each of these is its own commit, its own PR, its own author. They drift independently. You add a new translatable entity in March, you forget one of the five, and a Russian student reads English in their dashboard until somebody reports it.&lt;/p&gt;

&lt;p&gt;I shipped that bug twice in two months before I got tired of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the bug looks like in practice
&lt;/h2&gt;

&lt;p&gt;Concretely: I added an &lt;code&gt;Announcement&lt;/code&gt; table that has translatable &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;. Things I had to update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Postgres &lt;code&gt;CHECK&lt;/code&gt; constraint to add &lt;code&gt;'announcement'&lt;/code&gt; to the allowed &lt;code&gt;entity_type&lt;/code&gt; values. &lt;em&gt;Forgot this one.&lt;/em&gt; All inserts started failing with &lt;code&gt;IntegrityError&lt;/code&gt;. Took two days to notice because the route swallowed the exception and just logged.&lt;/li&gt;
&lt;li&gt;The Pydantic &lt;code&gt;EntityType = Literal[...]&lt;/code&gt; so the API contract types match.&lt;/li&gt;
&lt;li&gt;The SQLAlchemy &lt;code&gt;Mapped[Literal[...]]&lt;/code&gt; on the &lt;code&gt;ContentTranslation&lt;/code&gt; row.&lt;/li&gt;
&lt;li&gt;The tree walker that, when a course is published, decides what to translate. Had to add a new branch for announcements.&lt;/li&gt;
&lt;li&gt;The per-entity hook on the announcement-create route. Every other write route already called &lt;code&gt;reconcile_entity()&lt;/code&gt; after commit. This one didn't, so edits silently never propagated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five places. Each one is a single line or a single function call. None of them is hard. The problem is that &lt;strong&gt;the knowledge of "this entity is translatable" is duplicated&lt;/strong&gt;. Every duplicate is a chance to drift.&lt;/p&gt;
&lt;h2&gt;
  
  
  One registry, five readers
&lt;/h2&gt;

&lt;p&gt;Collapse the duplication into a declarative registry. Every layer reads from it. Adding a new entity is one entry plus one migration, end of story.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/app/services/translation/registry.py
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                     &lt;span class="c1"&gt;# logical name in our API
&lt;/span&gt;    &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;                   &lt;span class="c1"&gt;# column in DB
&lt;/span&gt;    &lt;span class="n"&gt;model_attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# ORM attr if it differs
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;
    &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;Course&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Course&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&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="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;_co&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Course title for «&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;EntityRegistration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;announcement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;resolve_course&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_resolve_course_via_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;build_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;ann&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Announcement on course «&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;»&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# ... seven more entries
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&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;frozenset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;frozenset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The same five layers now read from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pydantic schema&lt;/strong&gt; declares the &lt;code&gt;Literal&lt;/code&gt; once, by hand, in the module that other layers import from:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/app/schemas/protocol.py
&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;module&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chapter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chapter_block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz_question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiz_option&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assignment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;announcement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;course_event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cohort&lt;/span&gt;&lt;span class="sh"&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;That's the only manual list. A static test asserts it stays in lockstep with the registry:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_pydantic_literal_matches_registry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&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;get_args&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;SQLAlchemy model&lt;/strong&gt; imports the same &lt;code&gt;EntityType&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walker:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;collect_translatable_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entities_for_course&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;yield &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Per-entity hook:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reconcile_entity_if_course_published&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_REGISTRATIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve_course&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;run_translation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;course&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Migration:&lt;/strong&gt; still by hand, but it's the only place that doesn't auto-update. So we test it.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_postgres_check_constraint_matches_registry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_declared_entity_types_from_migration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;all_entity_types&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Migration CHECK constraint and registry are out of sync. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing in migration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stale in migration: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Now adding a new translatable entity is &lt;strong&gt;one new &lt;code&gt;EntityRegistration&lt;/code&gt; plus one migration that adds the value to the CHECK&lt;/strong&gt;. Both visible side by side in the same PR. The other three layers fix themselves at import time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The CI guard that makes drift impossible
&lt;/h2&gt;

&lt;p&gt;Even with the registry, one vulnerability remains. A new write route that mutates a registered entity but never calls the reconcile hook. Routes are individual files. There's nothing structural to stop the omission.&lt;/p&gt;

&lt;p&gt;The fix is a static test that introspects every FastAPI route at import time:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TRANSLATION_HOOK_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconcile_entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconcile_entity_if_course_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_course_translation_pipeline_if_published&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_writes_on_translatable_entity_call_a_hook&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every POST/PUT/PATCH whose path or body schema mentions a
    translatable entity must reference one of the canonical reconcile
    hooks somewhere in its source file.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;for&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;APIRoute&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PUT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_route_touches_translatable_entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inspect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getmodule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TRANSLATION_HOOK_NAMES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;These write endpoints touch a translatable entity but never call &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a reconcile hook:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;failures&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;Inverse rule for read endpoints:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_reads_returning_translatable_schemas_accept_language&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every GET that returns a translatable response schema must
    declare an Accept-Language parameter so the locale overlay applies.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Both are plain pytest. No real database, no live HTTP, no test client. They use FastAPI's &lt;code&gt;app.routes&lt;/code&gt; plus &lt;code&gt;inspect.getsource&lt;/code&gt;. They fail the build in under one second.&lt;/p&gt;

&lt;p&gt;The first time I ran them they immediately surfaced two real holes: the announcement-create route and the course-event-create route. Both had been merged for weeks. No user had hit them yet because both routes were teacher-only and had low traffic, but they would have failed the moment a teacher published an announcement on an EN-locale course with RU students enrolled.&lt;/p&gt;

&lt;p&gt;I added a &lt;code&gt;KNOWN_VIOLATIONS&lt;/code&gt; set to the test (cite the follow-up PR or issue, no silent skipping), fixed both routes the same hour, and emptied the set. The set has stayed empty since.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the pattern actually buys
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Adding a translatable entity is now a five-line PR.&lt;/strong&gt; One registry entry, one migration line. CI catches anything you forgot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures are loud, not silent.&lt;/strong&gt; The CI guard names the offending route. There is no "huh, why doesn't translation work for this endpoint" debugging session anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The registry doubles as documentation.&lt;/strong&gt; Want to know what gets translated? Read one file. There is no &lt;code&gt;git grep&lt;/code&gt; archaeology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern generalizes.&lt;/strong&gt; Anywhere a single fact about your domain has to be repeated across multiple layers, the same shape applies. Audit logging. RLS. Soft delete. Encryption. Each one is a registry plus a static test that introspects routes or models and demands they reference the canonical hook.&lt;/p&gt;

&lt;p&gt;That last bit is what made me write this post. The translation pipeline was the prompt, but the pattern is not about translation. It is about &lt;strong&gt;rejecting the idea that drift between layers is something you live with&lt;/strong&gt;. You don't have to. Static introspection of your own code is dirt cheap and almost nobody does it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;p&gt;The full implementation is in a small open-source LMS at &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;. Registry is &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;. CI guard is &lt;code&gt;backend/tests/test_translation_coverage.py&lt;/code&gt;. MIT-licensed, contributors welcome if any of this resonates.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>python</category>
      <category>fastapi</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Don't trust the LLM with scripture: a canonical-text substitution layer for Bible quotes</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 15:12:31 +0000</pubDate>
      <link>https://dev.to/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg</link>
      <guid>https://dev.to/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I'm building &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;equip&lt;/a&gt;, an open-source LMS for Bible schools. The product is bilingual (Russian and English) and most teacher-authored content goes through a Gemini-backed translation pipeline.&lt;/p&gt;

&lt;p&gt;For 95% of the content this is fine. Course titles, chapter prose, quiz questions, announcements, the LLM does an honest job and the worst that happens is a slightly clumsy turn of phrase.&lt;/p&gt;

&lt;p&gt;For Bible quotes it isn't fine. At all.&lt;/p&gt;

&lt;p&gt;A teacher writes a Russian course quoting Acts 1:8 from the Synodal translation, the canonical Russian-language text since 1876. An English-locale student reads it. The expected behaviour is that they see Acts 1:8 in the King James Version, the canonical English-language text since 1769. &lt;strong&gt;Not Gemini's interpretation of the Synodal text.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This isn't a stylistic preference. KJV and Synodal are the texts these communities cite, memorize, and study from. A model paraphrase, even a "good" one, breaks the contract: students need to read the same wording their pastor will quote on Sunday. And every LLM, including the strongest ones, paraphrases scripture. Sometimes subtly, sometimes egregiously.&lt;/p&gt;

&lt;p&gt;The naive fix is a prompt rule: "Bible verses must be preserved verbatim." It does not work. The model still paraphrases, especially across languages where there is no direct passthrough. So we built something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint
&lt;/h2&gt;

&lt;p&gt;Every quote we care about is one of two public-domain corpora:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;King James Version (1769)&lt;/strong&gt; for English. ~31,103 verses, ~4.5 MB as flat JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synodal (1876)&lt;/strong&gt; for Russian. ~30,111 verses, ~6.1 MB as flat JSON.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both bundle into the backend. They never change. They are the source of truth for any rendered Bible quote in either locale.&lt;/p&gt;

&lt;p&gt;The only problem is figuring out, for any given chunk of teacher-authored HTML, which substrings are quotes that need this canonical-text treatment, and what verse exactly each one is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The pipeline runs in three steps around each Gemini call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTML in source locale
        |
        v
  pre_substitute(html, source_locale)
   - find &amp;lt;blockquote&amp;gt; + reference pairs
   - confirm canonical via similarity match
   - swap verse text for VERSE_&amp;lt;hex&amp;gt; marker
        |
        v
   markered HTML  -----&amp;gt;  Gemini translate  -----&amp;gt;  translated HTML
                                                            |
                                                            v
                                              post_substitute(html, subs, target_locale)
                                               - replace each marker with
                                                 the canonical target-locale verse
                                               - localize the (Acts 1:8) reference too
                                                            |
                                                            v
                                                  HTML in target locale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  1. Detect
&lt;/h3&gt;

&lt;p&gt;We walk every &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; in the HTML. Two layouts in real-world content:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- A: reference inside the blockquote (most Synodal-style citations) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;...verse text... (Деян. 20:28).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- B: reference outside, immediately after &amp;lt;/blockquote&amp;gt; --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;...verse text...&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt; (Acts 1:8).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;We try the inside layout first, fall back to a 120-character lookahead window after the closing tag. The reference parser is a regex built from a 66-book canonical alias list (Matthew / Matt. / Mt. / Матфей / Мф. / Матф. / etc.) so a sloppy book pattern doesn't accidentally swallow surrounding prose like "See Acts" as a book name.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Confirm
&lt;/h3&gt;

&lt;p&gt;Detection alone isn't enough. The author might have paraphrased the verse themselves, or written commentary, or quoted only part of the verse. We don't want to "correct" intentional paraphrases.&lt;/p&gt;

&lt;p&gt;So for every detected blockquote+reference pair, we look up the canonical text in the source locale (Synodal for &lt;code&gt;ru&lt;/code&gt;, KJV for &lt;code&gt;en&lt;/code&gt;) and compare it to the author's text using &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt;. If similarity is at least 0.80, this is a real canonical quote and gets the substitution treatment. Below 0.80, we leave it alone and the LLM handles it under the existing "leave verses untouched" prompt rule (a fallback, not the main mechanism).&lt;/p&gt;

&lt;p&gt;We tested the threshold empirically on real course content. Author copy-pastes of Synodal hit 0.95 and above. Paraphrases land below 0.6. The 0.80 threshold tolerates minor punctuation differences (em-dashes, smart quotes, ё vs е normalization) without false-matching a paraphrase.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Substitute
&lt;/h3&gt;

&lt;p&gt;When we accept a quote, we replace the verse text with a marker:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VERSE_a1b2c3d4e5f6g7h8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Constraints this marker satisfies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain ASCII.&lt;/strong&gt; An earlier version used Unicode Private-Use Area characters as fences. They were invisible in the editor and silently broke ASCII assertions in tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres-TEXT-safe.&lt;/strong&gt; The first prototype used NUL-byte fences (&lt;code&gt;\x00...\x00&lt;/code&gt;). Postgres &lt;code&gt;TEXT&lt;/code&gt; rejects NUL. The translated marker came back stripped, leaving raw &lt;code&gt;VERSE_&amp;lt;hex&amp;gt;&lt;/code&gt; substrings visible to students. Took an embarrassing prod inspection to catch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifier-shaped.&lt;/strong&gt; This matters because Gemini's "preserve placeholders verbatim" prompt rule applies to identifier-shaped tokens. &lt;code&gt;VERSE_a1b2c3d4e5f6g7h8&lt;/code&gt; reads as a placeholder. &lt;code&gt;≪V≫&lt;/code&gt; does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Random hex suffix.&lt;/strong&gt; Multiple verses in one document each get a unique marker so substitutions round-trip independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also extend the marker leftwards through the opening parenthesis of the reference if there is one, so we don't leave a stray &lt;code&gt;(&lt;/code&gt; inside the marker-replaced verse text. The reference notation itself, &lt;code&gt;(Деян. 20:28).&lt;/code&gt;, is preserved as-is in the markered HTML and survives translation untouched (parens-with-digits looks like data to the model).&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Restore
&lt;/h3&gt;

&lt;p&gt;After Gemini returns the translated HTML, &lt;code&gt;post_substitute&lt;/code&gt; walks the substitution list and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replaces each marker with the canonical target-locale verse (&lt;code&gt;canonical_target = lookup(ref, target_locale)&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Falls back to the original source-locale text when the target lookup misses (e.g. a verse the bundled JSON happens to lack), better than leaving a marker visible.&lt;/li&gt;
&lt;li&gt;Rewrites the surviving reference notation to the target locale's conventional short form. &lt;code&gt;(Acts 1:8)&lt;/code&gt; becomes &lt;code&gt;(Деян. 1:8)&lt;/code&gt;. &lt;code&gt;(Матф. 28:19)&lt;/code&gt; becomes &lt;code&gt;(Matt. 28:19)&lt;/code&gt;. The book-name display table is keyed by the same canonical slug as the alias parser, so adding a new book is one row in two places.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What it looks like in production
&lt;/h2&gt;

&lt;p&gt;Russian-locale teacher writes:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Завершающее повеление Иисуса ученикам:&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;«Итак идите, научите все народы, крестя их во имя
Отца и Сына и Святаго Духа» (Матф. 28:19).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;English-locale student reads:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;The final command of Jesus to His disciples:&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;blockquote&amp;gt;&lt;/span&gt;Go ye therefore, and teach all nations, baptizing them in the
name of the Father, and of the Son, and of the Holy Ghost: (Matt. 28:19).&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Note three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The blockquote's verse text is the canonical KJV verbatim, not Gemini's interpretation of the Synodal.&lt;/li&gt;
&lt;li&gt;The reference is &lt;code&gt;(Matt. 28:19)&lt;/code&gt;, not &lt;code&gt;(Матф. 28:19)&lt;/code&gt;. Gemini didn't translate the book name. Our display table did.&lt;/li&gt;
&lt;li&gt;The surrounding prose ("The final command of Jesus to His disciples:") is the LLM doing its job on the parts the LLM should be doing.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Edge cases that bit us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Synodal short forms missing from the alias map.&lt;/strong&gt; First version had &lt;code&gt;матфей&lt;/code&gt;/&lt;code&gt;мф&lt;/code&gt;/&lt;code&gt;от матфея&lt;/code&gt; for Matthew but missed &lt;code&gt;Матф.&lt;/code&gt;, the most common abbreviation in actual Synodal-printed Bibles. Russian-authored content silently failed substitution and the verse leaked through Gemini paraphrased. Caught in production via Datadog RUM. Fixed by expanding aliases for every Gospel and Pauline epistle (&lt;code&gt;мар&lt;/code&gt;, &lt;code&gt;лук&lt;/code&gt;, &lt;code&gt;иоан&lt;/code&gt;, &lt;code&gt;фил&lt;/code&gt;, &lt;code&gt;1 фесс&lt;/code&gt;, &lt;code&gt;1 иоан&lt;/code&gt;, etc.) plus a contract test that asserts every canonical slug has an alias entry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reference outside the blockquote.&lt;/strong&gt; Some content puts the citation after &lt;code&gt;&amp;lt;/blockquote&amp;gt;&lt;/code&gt; instead of inside it. The first version captured the verse correctly but didn't track the outside reference, so a Russian student saw a Synodal verse next to a stray English &lt;code&gt;(Acts 1:8)&lt;/code&gt;. Fixed by storing the outside ref text on the substitution record and running the same locale rewrite on it during post.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Marker spacing.&lt;/strong&gt; The marker swallowed the trailing whitespace and closing curly quote of the blockquote, so the post-substituted output read &lt;code&gt;…canonical text.(Matt. 28:19).&lt;/code&gt; with no space. Re-introduced a single ASCII space when the tail starts with &lt;code&gt;(&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verse range references.&lt;/strong&gt; &lt;code&gt;(Acts 1:8-10)&lt;/code&gt; localizes correctly to &lt;code&gt;(Деян. 1:8-10)&lt;/code&gt; because the display formatter respects the &lt;code&gt;verse_end&lt;/code&gt; field on the ref struct. The corresponding canonical lookup joins the verses with a single space and falls back to None if any verse in the range is missing.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What's interesting about this approach
&lt;/h2&gt;

&lt;p&gt;The Bible substitution layer doesn't compete with the LLM. It uses the LLM for what it's good at (translating prose, preserving HTML structure, transliterating proper nouns) and replaces the LLM where the LLM is wrong (touching canonical text). Each layer has a clean job.&lt;/p&gt;

&lt;p&gt;The same pattern applies anywhere you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small, public-domain or licensed corpus of canonical text&lt;/li&gt;
&lt;li&gt;A larger surface that needs LLM translation&lt;/li&gt;
&lt;li&gt;A reliable way to detect quotes from the corpus inside the surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples I can think of: legal contracts citing statute, scientific writing citing equations or named constants, classical literature quoting older works in their established translations. The shape is the same. Detect the canonical chunk, swap it for a placeholder, let the LLM handle the surrounding prose, restore the canonical chunk in the target locale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;All of this lives in &lt;code&gt;backend/app/services/bible/&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;books.py&lt;/code&gt;: 66-book canon, alias map, per-locale display names&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;references.py&lt;/code&gt;: regex parser built from the alias list&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;store.py&lt;/code&gt;: bundled JSON loader (KJV / Synodal)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;substitution.py&lt;/code&gt;: pre/post substitute, similarity threshold, marker tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data/&lt;/code&gt;: kjv-en.json (4.5 MB) + synodal-ru.json (6.1 MB), both public-domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;39 unit tests cover the alias map, the reference parser, the locale store, full round-trips for both directions, the verse-range case, and the spacing regression. The pipeline integrates into the broader translation registry which also handles non-Bible content.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt; (MIT)&lt;/p&gt;
&lt;h2&gt;
  
  
  How you can help
&lt;/h2&gt;

&lt;p&gt;The pipeline above is one corner of a small open-source LMS for Bible schools and volunteer-run training programs. If you've worked on translation pipelines, LLM I/O hardening, or just like the idea of an LMS that respects scripture as a source of truth, the issues tab is open. Star the repo if you want to follow along.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Two weeks of building Equip in public: first contributor, bilingual content, real production bug</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sun, 10 May 2026 02:13:11 +0000</pubDate>
      <link>https://dev.to/arvavit/two-weeks-of-building-bible-school-lms-in-public-first-contributor-bilingual-content-real-11am</link>
      <guid>https://dev.to/arvavit/two-weeks-of-building-bible-school-lms-in-public-first-contributor-bilingual-content-real-11am</guid>
      <description>&lt;h2&gt;
  
  
  Two weeks ago I posted &lt;a href="https://dev.to/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod"&gt;"Open Source Equip, we need your help"&lt;/a&gt; and asked for contributors.
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt; (MIT, looking for stars and PRs)&lt;br&gt;
Live: &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The first community PR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Kushalbg-06" rel="noopener noreferrer"&gt;Kushal&lt;/a&gt; picked up a &lt;code&gt;good first issue&lt;/code&gt; (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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  What shipped: bilingual content (RU and EN)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;How it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Every teacher-authored field (course title, module, chapter, rich-text block, quiz, assignment, announcement, calendar event, cohort) is registered once in &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;. 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 &lt;code&gt;CHECK&lt;/code&gt; constraint update.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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 &lt;code&gt;public.content_translations&lt;/code&gt; keyed by &lt;code&gt;(entity_type, entity_id, field, locale)&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The student's &lt;code&gt;Accept-Language&lt;/code&gt; 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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A static CI guard introspects every FastAPI route. If a GET that returns a translatable schema is missing &lt;code&gt;Accept-Language&lt;/code&gt;, 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.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The interesting part: Bible quotes do not go through the LLM
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the pipeline pre-substitutes around the LLM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The translator detects &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; plus a parenthesised reference like &lt;code&gt;(Acts 1:8)&lt;/code&gt; or &lt;code&gt;(Деян. 20:28)&lt;/code&gt;, in the inside-the-quote layout and the outside-the-quote layout.&lt;/li&gt;
&lt;li&gt;It compares the author's quoted text to the bundled canonical source-locale verse using &lt;code&gt;SequenceMatcher&lt;/code&gt;. If similarity is at least 0.80, it is a real canonical quote and gets replaced with an ASCII marker like &lt;code&gt;VERSE_a1b2c3d4e5f6g7h8&lt;/code&gt; before the request goes to Gemini.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;The reference itself, &lt;code&gt;(Acts 1:8)&lt;/code&gt;, is also rewritten in the target locale's conventional short form, &lt;code&gt;(Деян. 1:8)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;There are 66 books in the canonical Protestant canon, each with a list of recognised aliases (including Synodal abbreviations like &lt;code&gt;Матф.&lt;/code&gt; and &lt;code&gt;Деян.&lt;/code&gt; that the first version of the parser quietly missed). All 66 are in regression tests.&lt;/p&gt;
&lt;h2&gt;
  
  
  What observability caught the day we plugged it in
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Failed to fetch dynamically imported module:
    .../assets/ChapterView-DYS-mrkM.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the classic Vite SPA stale-chunk failure. After a deploy, every open tab is still holding the old &lt;code&gt;index.html&lt;/code&gt; whose &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  What else moved (compressed list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Auth callback page is fully translated now. Russian users no longer see English during email confirmation or password reset.&lt;/li&gt;
&lt;li&gt;Course detail page is fully translated, including the admin "manage course" flow that previously hid the enroll button.&lt;/li&gt;
&lt;li&gt;OpenGraph and Twitter cards added so links to courses unfurl properly when pasted into Slack, Telegram, X, LinkedIn.&lt;/li&gt;
&lt;li&gt;Backend favicon is now a real dark-variant of the brand glyph instead of an empty 204.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Where contributors fit in
&lt;/h2&gt;

&lt;p&gt;Same answer as two weeks ago, with sharper edges:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  One line again
&lt;/h2&gt;

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

&lt;p&gt;&lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading. See you in the issues tab.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Equip — open-source Bible LMS, we need your help (React, FastAPI, Supabase)</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sat, 25 Apr 2026 05:01:11 +0000</pubDate>
      <link>https://dev.to/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod</link>
      <guid>https://dev.to/arvavit/open-source-bible-school-lms-we-need-your-help-react-fastapi-supabase-4hod</guid>
      <description>&lt;h2&gt;
  
  
  Why I’m writing this
&lt;/h2&gt;

&lt;p&gt;Small Bible schools, home groups, and volunteer-run training programs still run classes on &lt;strong&gt;paper, messengers, and spreadsheets&lt;/strong&gt;. Big LMS products are either &lt;strong&gt;too expensive&lt;/strong&gt; or &lt;strong&gt;too heavy&lt;/strong&gt; for a team that has no DevOps and no budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is a &lt;strong&gt;free, open-source&lt;/strong&gt; learning platform built for that reality: &lt;strong&gt;tens to low hundreds of users&lt;/strong&gt;, not “enterprise 10k seats”.&lt;/p&gt;

&lt;p&gt;We’re on &lt;strong&gt;&lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;GitHub (MIT)&lt;/a&gt;&lt;/strong&gt; and we’d love &lt;strong&gt;contributors&lt;/strong&gt; — code, UI, docs, accessibility, and ideas.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Courses&lt;/strong&gt; → modules → chapters with a &lt;strong&gt;TipTap&lt;/strong&gt; rich editor (text, images, YouTube, callouts, audio).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quizzes&lt;/strong&gt; — multiple choice, true/false, short answer, essay; teacher grading and extra attempts when needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assignments&lt;/strong&gt; + &lt;strong&gt;gradebook&lt;/strong&gt;, &lt;strong&gt;progress&lt;/strong&gt;, &lt;strong&gt;certificates&lt;/strong&gt; (with approval flow).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teacher &amp;amp; admin&lt;/strong&gt; tools: cohorts, calendar, announcements, analytics, soft-delete / restore, CSV export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth&lt;/strong&gt; via &lt;strong&gt;Supabase&lt;/strong&gt; (email/password + Google).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt;: &lt;strong&gt;Vercel&lt;/strong&gt; (static frontend + serverless &lt;strong&gt;FastAPI&lt;/strong&gt; backend), &lt;strong&gt;Postgres&lt;/strong&gt; on Supabase with &lt;strong&gt;RLS&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Roadmap:&lt;/strong&gt; in the repo → &lt;code&gt;ROADMAP.md&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;How to contribute:&lt;/strong&gt; &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Stack (if you like concrete tech)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tech&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;React 18, TypeScript, Vite, Tailwind, shadcn/ui, TipTap, Radix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Python 3.12, FastAPI, SQLAlchemy 2, Pydantic 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;PostgreSQL (Supabase), migrations in &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;GitHub Actions — lint, typecheck, tests (backend + frontend)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We keep &lt;strong&gt;conventional commits&lt;/strong&gt;, &lt;strong&gt;no Docker&lt;/strong&gt; in the project by design, and we care about &lt;strong&gt;types&lt;/strong&gt; (mypy + strict TypeScript) and a &lt;strong&gt;clean&lt;/strong&gt; codebase.&lt;/p&gt;


&lt;h2&gt;
  
  
  How you can help (no need to be a “10×” developer)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good first issues&lt;/strong&gt; — filter by &lt;code&gt;good first issue&lt;/code&gt; on GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI/UX&lt;/strong&gt; — especially accessibility and a calmer, “editorial” reading experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs&lt;/strong&gt; — setup stories from real machines (Windows / macOS / Linux) help a lot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n&lt;/strong&gt; — the product UI is largely &lt;strong&gt;Russian&lt;/strong&gt; today; if you care about &lt;strong&gt;internationalization&lt;/strong&gt;, that’s a meaningful roadmap area.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open a &lt;strong&gt;draft PR&lt;/strong&gt;, ask in an &lt;strong&gt;issue&lt;/strong&gt;, or &lt;strong&gt;fork&lt;/strong&gt; and experiment — we’re building in public and want this to stay &lt;strong&gt;approachable&lt;/strong&gt; for nonprofits and volunteers.&lt;/p&gt;


&lt;h2&gt;
  
  
  One line pitch
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Free LMS, small scale, real classrooms — if that resonates, star the repo and pick an issue.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thanks for reading — hope to see you in the issues tab 🤝&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>react</category>
      <category>fastapi</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
