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 a live game 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.2.0. Every rule is proven by specs that run on every push, and each of 132 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.2.0", target = "roblox" }
LocaleBlox has no dependencies. It reads no Roblox service, so the same modules load on the
client, on the server and off Roblox. The two modules that write on instances (Label,
Localize) are handed the instance and the collection service by the game.
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 camp moved to the %s",
zoneHarbour = "Harbour",
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 = "zoneHarbour" })
progress.Text = text.count("pieces", carried, total)
What stays in a game
Its tables, its plural forms and its name tables, the names of its data, the JSON codec its sentences are written with, 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 and options.names are optional.
options.locale names the table to read when it is already known (a server writing in one
locale, a spec walking every table) and is used as given.
| Function | What it returns |
|---|---|
get(key, ...) | The string for key, formatted with string.format when arguments follow. An argument { key = "..." } is looked up first, and so is { <kind> = "<id>" } for a kind given in names, 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)). |
name(kind, id) | The name of one of the game's things in this locale (Names); the id itself when the kind was not given or this locale does not name it. |
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.
Names
The display names of a game's things (creatures, items, places): one table a locale, keyed by the thing's own id. The id is what the game saves and matches and never changes; only what is drawn goes through here. A locale with no table, and an id its table lacks or leaves empty, show the id, so a game whose ids are its English names ships no English table.
local PETS: LocaleBlox.NameTables = { ru = require(names.ru), ja = require(names.ja) }
local text = LocaleBlox.Text.new({ tables = Tables, names = { pet = PETS }, localeId = player.LocaleId })
line.Text = text.get("hatched", ownerName, { pet = "Goldbun" })
title.Text = text.name("pet", "Goldbun")
Names.get(tables, locale, id) is the lookup; Names.coverage(tables, ids) counts how many ids
each locale names, fullest first; Names.keys(tables) lists every id any table names with the
locales that name it.
Said, Label, Localize
Text a server puts on a replicated instance (a sign, a prompt, a billboard) is written as a key and its arguments, and each client draws it in its own language.
-- shared: the game's sentences, over its own JSON codec
local said = LocaleBlox.Said.new({
encode = function(value) return HttpService:JSONEncode(value) end,
decode = function(text) return HttpService:JSONDecode(text) end,
})
-- server
local label = LocaleBlox.Label.new({ said = said, english = englishReader })
label.set(sign, "Text", "priceOf", { { pet = "Goldbun" }, "12K" })
label.plain(sign, "Text", "1,250") -- no language: drops the sentence
-- client
LocaleBlox.Localize.start({ said = said, text = text, collection = CollectionService })
Said.new(codec):encode(key, args?)writes a sentence to one string anddecode(text)reads it back (nil for anything that is not one);read(reader, encoded)is the sentence in a reader's language,""for anything else;same(keyA, argsA, keyB, argsB)says whether two sentences would encode alike without encoding either, andcopy(args)keeps a caller's arguments as they were. An argument is a string, a number,{ key = "..." }or{ <kind> = "<id>" }.Label.new({ said, english, tag?, prefix? }):set(instance, property, key, args?)writes the English into the property (so it is never blank before a client has run), the sentence into the attributeSaid_<property>, and tags the instanceLocalized. Setting the same sentence again does nothing.english(key, args?)is the sentence in English; one whose arguments do not fit reads as its bare template.Localize.start({ said, text, collection, tag?, prefix? })rewrites every tagged instance in the reader's language, those there and those that come, and again whenever the server says something new or its English lands late.
A sentence need not ride an instance: said.encode gives a string for any wire, and said.read
reads it.
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", "double_pay", "blurb") is "productDoublePayBlurb".
The lint
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. The type is any type written on that line (Table,{ [string]: string }) or none.aliasesrenames a variable to its locale code, which is also how a names file whose table is callednamesbecomes its locale's.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.names(names, ids, ui?, locales?, where?): every locale asked for has a table, every table names every id (a missing or empty name would show the id), no two ids share a name in one locale, no name is a word of its own locale's UI, and no table names an id the game does not have. Each name's writing isWritten.one's to check.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.