feat(dialects): a catalogue of the languages most spoken in Africa

Fifty-six entries -- fifty-three African languages and three European ones --
for populating a group's dialects without typing each into a three-field form.
Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md.

The unit is a language *in a country* rather than a language, because that is
what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili
is three entries and that is the point rather than a duplication.

(code, country) is the identity and the name is deliberately not part of it.
Nothing anywhere makes a dialect unique -- the id is a content hash including
createdAt, there is no index, and the rows are quorum-signed so they cannot be
rewritten -- so duplication has to be prevented at proposal time, and of the
three fields the name is the only one that is a matter of spelling.

Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than
639-3 throughout: a uniform `sot` would disagree with the `st` that every room
which used the form has already signed.

Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai);
639-1's `lu` is Luba-Katanga, a different language of the same country, and
taking the two-letter code because it was there would have named the wrong one.
And the three European languages are a list of their own rather than lines
buried in the African one, so dropping them is one edit.

It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string
literals and title case, and "Northern Sotho" is title case for the same reason
m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of
reach is cheaper and more honest than an m3-string-exempt marker on a file that
is not UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 22:34:29 +02:00
parent 5105d8bd3c
commit 9d34a2cc99
3 changed files with 794 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
package press.mantra.compose.language
/**
* A language the catalogue offers, in one of the countries it is spoken in.
*
* The unit is the pair rather than the language. A dialect in this app is
* `(name, country, code)` -- see `MantraDialect` -- so Kiswahili is three
* entries, one for Kenya, one for Tanzania and one for the DR Congo, and that is
* the point rather than a duplication: a group translating for Kenyan readers is
* not translating for Tanzanian ones. The whole reason the kind is called a
* dialect is that it is finer than a language.
*
* [code] and [country] together are the identity. [name] is deliberately not
* part of it: a member typing "Sesotho" into the form and a catalogue saying
* "Sesotho" agree today and disagree the moment one of them carries a diacritic,
* and of the three fields the name is the only one that is a matter of spelling
* rather than of fact. See `DialectScaffold`.
*/
data class AfricanLanguage(
/**
* What the dialect row will be called.
*
* The name a speaker would recognise, which means the endonym wherever that
* is also the usual name in English -- Sesotho, isiZulu, Kiswahili -- and the
* English name where it is not. These are judgment calls and a reviewer is
* welcome to overrule any of them; nothing matches on this field.
*/
val name: String,
/**
* ISO 639-1 where the language has one, ISO 639-3 otherwise.
*
* Not 639-3 throughout, which would be more uniform and worse here: it would
* make this catalogue's `sot` disagree with the `st` that every room which
* used the hand-typed form has already signed, and those rows cannot be
* rewritten -- a quorum signed them. The form's placeholder has always said
* two letters (`eg. st`).
*/
val code: String,
val country: String,
)
/**
* The languages most spoken in Africa, for populating a group's dialects without
* typing each one into a three-field form.
*
* Reference data, deliberately outside `ui/`. `docs/scripts/m3-audit.sh` scans
* `ui/` and `strings.xml` for string literals and title case, and "Northern
* Sotho" is title case for exactly the reason `m3-title-case.py` keeps a
* `SAMPLE` set of proper nouns at all. Keeping the catalogue out of `ui/` is
* cheaper and more honest than an `m3-string-exempt` marker on a file that is
* not UI.
*
* Also not a composeResource: there is no `files/` directory under
* `composeResources`, a bundled JSON would need a parser and a failure mode, and
* a Kotlin list is compile-checked, greppable and testable.
*
* **[ALL] has to stay under `FrostSigningManager.MAX_BATCH_SIZE`** or a wave
* stops being one signing session. `DialectCatalogueSizeTest` is the tripwire,
* because somebody adding a language has no reason to be thinking about FROST.
*
* Not exhaustive and not trying to be. It is the cross-border lingua francas and
* the high-population national languages; a group wanting a language this does
* not list still has the form.
*/
object AfricanLanguages {
/**
* Ordered by country, then by how widely the language is spoken there, so a
* reader scanning for theirs scans once. `AfricanLanguagesTest` holds it to
* that, which is why the European languages are a list of their own: they
* are appended, and appending them would break the grouping.
*/
val african: List<AfricanLanguage> = listOf(
AfricanLanguage("Kabyle", "kab", "Algeria"),
AfricanLanguage("Umbundu", "umb", "Angola"),
AfricanLanguage("Kimbundu", "kmb", "Angola"),
AfricanLanguage("Setswana", "tn", "Botswana"),
AfricanLanguage("Mooré", "mos", "Burkina Faso"),
AfricanLanguage("Kirundi", "rn", "Burundi"),
AfricanLanguage("Sango", "sg", "Central African Republic"),
// `lua` is Luba-Kasai, which is what Tshiluba means. ISO 639-1's `lu` is
// Luba-Katanga, a different language of the same country, and taking the
// two-letter code because it was there would have named the wrong one.
AfricanLanguage("Lingala", "ln", "Democratic Republic of the Congo"),
AfricanLanguage("Kiswahili", "sw", "Democratic Republic of the Congo"),
AfricanLanguage("Tshiluba", "lua", "Democratic Republic of the Congo"),
AfricanLanguage("Kikongo", "kg", "Democratic Republic of the Congo"),
AfricanLanguage("Arabic", "ar", "Egypt"),
AfricanLanguage("Tigrinya", "ti", "Eritrea"),
AfricanLanguage("siSwati", "ss", "Eswatini"),
AfricanLanguage("Amharic", "am", "Ethiopia"),
AfricanLanguage("Oromo", "om", "Ethiopia"),
AfricanLanguage("Akan", "ak", "Ghana"),
AfricanLanguage("Ewe", "ee", "Ghana"),
AfricanLanguage("Ga", "gaa", "Ghana"),
AfricanLanguage("Kiswahili", "sw", "Kenya"),
AfricanLanguage("Gikuyu", "ki", "Kenya"),
AfricanLanguage("Dholuo", "luo", "Kenya"),
AfricanLanguage("Sesotho", "st", "Lesotho"),
AfricanLanguage("Malagasy", "mg", "Madagascar"),
AfricanLanguage("Chichewa", "ny", "Malawi"),
AfricanLanguage("Bambara", "bm", "Mali"),
AfricanLanguage("Arabic", "ar", "Morocco"),
AfricanLanguage("Tamazight", "zgh", "Morocco"),
AfricanLanguage("Emakhuwa", "vmw", "Mozambique"),
AfricanLanguage("Hausa", "ha", "Niger"),
AfricanLanguage("Hausa", "ha", "Nigeria"),
AfricanLanguage("Yoruba", "yo", "Nigeria"),
AfricanLanguage("Igbo", "ig", "Nigeria"),
AfricanLanguage("Kinyarwanda", "rw", "Rwanda"),
AfricanLanguage("Wolof", "wo", "Senegal"),
AfricanLanguage("Fulfulde", "ff", "Senegal"),
AfricanLanguage("Krio", "kri", "Sierra Leone"),
AfricanLanguage("Somali", "so", "Somalia"),
// `nr` is South Ndebele and `nd` is North Ndebele, and both are called
// isiNdebele by their speakers. The country tells them apart, which is
// what a dialect being a language in a place is for.
AfricanLanguage("isiZulu", "zu", "South Africa"),
AfricanLanguage("isiXhosa", "xh", "South Africa"),
AfricanLanguage("Afrikaans", "af", "South Africa"),
AfricanLanguage("Sepedi", "nso", "South Africa"),
AfricanLanguage("Setswana", "tn", "South Africa"),
AfricanLanguage("Xitsonga", "ts", "South Africa"),
AfricanLanguage("siSwati", "ss", "South Africa"),
AfricanLanguage("Tshivenda", "ve", "South Africa"),
AfricanLanguage("isiNdebele", "nr", "South Africa"),
AfricanLanguage("Arabic", "ar", "Sudan"),
AfricanLanguage("Kiswahili", "sw", "Tanzania"),
AfricanLanguage("Luganda", "lg", "Uganda"),
AfricanLanguage("Bemba", "bem", "Zambia"),
AfricanLanguage("Shona", "sn", "Zimbabwe"),
AfricanLanguage("isiNdebele", "nd", "Zimbabwe"),
)
/**
* Not African languages, and among the most spoken on the continent.
*
* Included because a catalogue that cannot offer a group the language it
* actually publishes in is a catalogue that group will not use, and one
* country each because these are the countries the most speakers are in
* rather than everywhere the language is official.
*
* Leaving them out would make this a catalogue of African languages instead,
* which is a defensible thing to want -- so they are a list of their own
* rather than three lines buried in [african], and dropping them is one edit
* to [ALL].
*/
val european: List<AfricanLanguage> = listOf(
AfricanLanguage("French", "fr", "Democratic Republic of the Congo"),
AfricanLanguage("Portuguese", "pt", "Angola"),
AfricanLanguage("English", "en", "Nigeria"),
)
/** Every entry the picker offers. */
val ALL: List<AfricanLanguage> = african + european
}

View File

@@ -0,0 +1,126 @@
package press.mantra.compose.language
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The catalogue, held to the shape the rest of the feature reads it with.
*
* Nothing here is about which languages are in it -- that is a judgment call and
* a reviewer's to make. It is about the invariants `DialectScaffold` matches on,
* every one of which fails silently rather than loudly if it is broken: a
* duplicated pair proposes the same dialect twice in one wave and the two do not
* even collide, because their ids differ by `createdAt`.
*/
class AfricanLanguagesTest {
@Test
fun everyCodeIsAnIsoCodeShape() {
AfricanLanguages.ALL.forEach { language ->
assertEquals(
language.code.lowercase(),
language.code,
"${language.name}'s code is not lowercase, and matching lowercases both sides"
)
assertTrue(
language.code.length == 2 || language.code.length == 3,
"${language.name}'s code ${language.code} is neither ISO 639-1 nor 639-3"
)
assertTrue(
language.code.all { it in 'a'..'z' },
"${language.name}'s code ${language.code} is not letters"
)
}
}
@Test
fun everyEntryIsNamedAndPlaced() {
AfricanLanguages.ALL.forEach { language ->
assertTrue(language.name.isNotBlank(), "an entry for ${language.code} has no name")
assertTrue(
language.country.isNotBlank(),
"${language.name} is not in a country, and a dialect is a language in a place"
)
assertEquals(
language.name.trim(),
language.name,
"${language.name} has whitespace around it, which will end up in a signed event"
)
assertEquals(
language.country.trim(),
language.country,
"${language.name}'s country has whitespace around it"
)
}
}
/**
* The identity rule. Two entries sharing a code and a country are one
* dialect proposed twice, and `DialectScaffold.missingFrom` cannot tell them
* apart because there is nothing to tell apart.
*/
@Test
fun codeAndCountryTogetherAreUnique() {
val duplicates = AfricanLanguages.ALL
.groupBy { it.code to it.country }
.filterValues { it.size > 1 }
assertEquals(
emptyMap(),
duplicates,
"these (code, country) pairs appear more than once"
)
}
/**
* A language in two countries is two entries, which is the whole model. Here
* as an assertion rather than a comment because the uniqueness test above
* reads as though it forbids this, and it does not.
*/
@Test
fun aLanguageMayAppearInSeveralCountries() {
val swahili = AfricanLanguages.ALL.filter { it.code == "sw" }
assertTrue(swahili.size > 1, "Kiswahili is spoken in more than one country")
assertEquals(
swahili.size,
swahili.map { it.country }.distinct().size,
"Kiswahili's entries do not name distinct countries"
)
}
/**
* Grouped by country, as the list says it is.
*
* Contiguity rather than alphabetical order: what a reader needs is for
* their country's languages to be together, and `african` is the list that
* promises it -- the European entries are appended and would break it.
*/
@Test
fun theAfricanEntriesAreGroupedByCountry() {
val countries = AfricanLanguages.african.map { it.country }
val firstSeen = countries.distinct()
assertEquals(
firstSeen,
countries.zipWithNext()
.filter { (previous, next) -> previous != next }
.map { (_, next) -> next }
.let { listOf(countries.first()) + it },
"a country's languages are split apart in the list"
)
}
@Test
fun allIsTheTwoListsAndNothingElse() {
assertEquals(
AfricanLanguages.african + AfricanLanguages.european,
AfricanLanguages.ALL
)
assertTrue(
AfricanLanguages.african.isNotEmpty(),
"a catalogue of African languages with no African languages in it"
)
}
}