xopoiii/localeblox
A Roblox game's words in twenty-five languages: Luau tables looked up by key, plural forms, and the lint that keeps the tables whole
LocaleBlox
A Roblox game's words in twenty-five languages: Luau tables looked up by key, plural forms, and the lint that keeps the tables whole.
LocaleBlox is the locale machinery of the game Grabby Pit as a package. A game keeps its strings in
plain Luau tables, one per language; LocaleBlox picks the table from the player's Roblox locale,
formats a key into a sentence, makes a noun agree with its number, and checks that every language
holds every key with the same placeholders, in its own script. There is no LocalizationService,
no cloud table and nothing to upload.
Status: 0.1.1. Every rule is proven by specs that run on every push, and each of 73 small slips in them makes the suite fail (
tests/Mutate.luau). The whole library is plain Luau and runs on LuneBlox, the Luau version Roblox runs. It was required once in a real Roblox server and every module answered there; no game has shipped with it yet.
Install
pesde add xopoiii/localeblox -t roblox -a LocaleBlox
or pin it exactly in pesde.toml:
[dependencies]
LocaleBlox = { name = "xopoiii/localeblox", version = "=0.1.1", target = "roblox" }
LocaleBlox has no dependencies. It touches no Roblox service, so the same modules load on the client, on the server and off Roblox.
A game's words in a minute
local LocaleBlox = require(path.to.LocaleBlox)
-- The game's own tables stay in the game: one key a line, English as the source.
local en: LocaleBlox.Table = {
feed = "Feed",
movedTo = "%s's colony moved to the %s",
worldGarden = "Garden",
pieces = "%d / %d pieces carried off",
}
local plurals: LocaleBlox.Plurals = {
en = { pieces = { one = "%d / %d piece carried off", other = "%d / %d pieces carried off" } },
}
-- One reader per client, from the player's LocaleId.
local text = LocaleBlox.Text.new({
tables = { en = en, ru = ru },
plurals = plurals,
localeId = Players.LocalPlayer.LocaleId,
})
button.Text = text.get("feed")
line.Text = text.get("movedTo", ownerName, { key = "worldGarden" })
progress.Text = text.count("pieces", carried, total)
What stays in a game
Its tables and its plural forms, the names of its data, how a sentence travels from its server (as a key and arguments, over its own wire), and the scripts that read its files and call the lint.
The API
Text
Text.new(options) returns a reader. options.tables is the game's tables by locale, with English
required; options.localeId is any BCP-47 tag; options.plurals is optional.
| Function | What it returns |
|---|---|
get(key, ...) | The string for key, formatted with string.format when arguments follow. An argument { key = "..." } is looked up first, so a translated sentence never gets an English word in a slot. With no arguments the template is returned as written. |
count(key, n, ...) | A sentence whose noun agrees with n, which is the first format argument. Without forms for the key it is the flat string. |
data(kind, name, field?) | get(Keys.of(kind, name, field)). |
has(key) | Whether the key has text in this locale or in English. |
locale(), localeId() | The resolved locale, and the id it came from. |
isRtl() | Whether this locale is written right to left. |
Amounts are written by the game ("12.5K") and ride %s.
Plural forms. plurals[locale][key][category], the category being CLDR's: zero, one, two,
few, many, other. A missing category falls back to other, then many, then one. A locale
with a table and no forms for a key uses its flat string, which is right for a language whose noun
does not change. A locale with no table at all counts in English, by English's rules.
Locale
Locale.localeFor(localeId): the locale a LocaleId reads. Case and the separator do not matter.beandkkreadru;tlreadsfil;zh-hant,zh-tw,zh-hkandzh-moreadzh-hantand every other Chinese tag readszh-hans;es-es,pt-ptandfr-careades-ES,pt-PTandfr-CA; anything unknown readsen.Locale.router(tables):get(locale, key)with the fallback locale, English, key;keys(locale)andpopulated(), sorted;localeFor. It errors if there is no English table.Locale.isRtl(locale): true forar.Locale.ALL: the twenty-five locales, frozen:en,ru,pt,es,id,de,fr,fil,hi,vi,th,ms,ja,ko,zh-hant,tr,pl,ar,it,nl,uk,zh-hans,es-ES,pt-PT,fr-CA.
Plural
Plural.category(locale, n): Russian and Ukrainian have one, few, many; Polish the same with
one only at exactly one; Arabic all six; French counts zero with one; English, German, Dutch,
Spanish, Portuguese, Italian, Hindi and Filipino have one and other; every other locale is
other. n is taken whole and without its sign.
Keys
Keys.of(kind, name, field?): Keys.of("food", "sugar_cube") is "foodSugarCube", and
Keys.of("product", "pay_rush", "blurb") is "productPayRushBlurb".
The lint
Four modules that take text and tables and return a sorted list of problems, each a line a person can act on. An empty list is a clean result. A game's script reads its own files and calls them; a game's spec can call them on the tables it requires.
local locales: LocaleBlox.Parsed = {}
for _, path in localeFiles do
LocaleBlox.Parse.source(fs.readFile(path), locales, { zhHans = "zh-hans", zhHant = "zh-hant" })
end
local problems = LocaleBlox.Lint.tables(locales)
Parse.source(source, out, aliases?)adds every table of a Luau source toout[code][key]. A table opens withlocal name: Type = {(orconst) on a line of its own, holds onekey = "value",per line one tab deep, and closes with}at the start of a line.aliasesrenames a variable to its locale code.Parse.specifiers(text)lists a string'sstring.formatspecifiers.Lint.tables(locales): every English key is in every locale and no locale holds a key English lacks; a translation has English's specifiers in number and order; no empty string; no combining acute accent; no Simplified glyph inzh-hantand no Traditional glyph inzh-hans; one width of "!" and "?" per Chinese table.Lint.plurals(en, plurals): every counted key is in English, every category is CLDR's, every form has English's specifiers, and every set of forms hasotherormany.Written.check(locales, options?)andWritten.one(code, where, text, options?): valid UTF-8, no U+FFFD, no control character, no combining accent; every locale in its own script; Arabic that starts with an Arabic letter and holds no Latin word.options.keepis a list of Lua patterns for Latin a translation keeps on purpose; format specifiers, multipliers ("x2"), "R$", "Robux" and "Roblox" are always kept.Usage.check(en, sources, options?):sourcesis a list of{ path, text }. A string literal shaped like a key (a lowercase word, then a capital) whose first word starts some English key must be a key, and every English key must appear in a source.options.datanames the first words of keys built from game data, which are left to the game's own spec.
Working on it
rokit install, lefthook install, then sh scripts/run-tests.sh and
luneblox run tests/Mutate --yes. CLAUDE.md holds the rules and the release steps.
Licence
MIT.