This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,12 @@
{
"language": {
"mode": "nonstrict"
},
"lint": {
"LocalShadow": "fatal",
"LocalUnused": "fatal",
"ImportUnused": "fatal",
"ImplicitReturn": "fatal",
"DeprecatedGlobal": "fatal"
}
}
@@ -0,0 +1,100 @@
local CorePackages = game:GetService("CorePackages")
local LocalizationService = game:GetService("LocalizationService")
local Roact = require(CorePackages.Roact)
local ExternalEventConnection = require(CorePackages.RoactUtilities.ExternalEventConnection)
local ArgCheck = require(CorePackages.ArgCheck)
local LocalizationKey = require(CorePackages.Localization.LocalizationKey)
local LocalizationConsumer = Roact.Component:extend("LocalizationConsumer")
function LocalizationConsumer:init()
local localization = self._context[LocalizationKey].localization
if localization == nil then
error("LocalizationConsumer must be below a LocalizationProvider.")
end
self.state = {
locale = LocalizationService.RobloxLocaleId,
}
self.updateLocalization = function(newLocale)
if settings():GetFFlag("AppBridgeStartupController") then
newLocale = localization:GetLocale()
end
if newLocale ~= self.state.locale then
self:setState({
locale = newLocale
})
end
end
if settings():GetFFlag("AppBridgeStartupController") then
self.connections = {
localization.changed:connect(self.updateLocalization)
}
end
end
function LocalizationConsumer:willUnmount()
if settings():GetFFlag("AppBridgeStartupController") then
for _, connection in pairs(self.connections) do
connection:disconnect()
end
end
end
function LocalizationConsumer:render()
local localization = self._context[LocalizationKey].localization
local render = self.props.render
local stringsToBeLocalized = self.props.stringsToBeLocalized
ArgCheck.isType(render, "function", "LocalizationConsumer.props.render")
ArgCheck.isType(stringsToBeLocalized, "table", "LocalizationConsumer.props.stringsToBeLocalized")
local localizedStrings = {}
for stringName, stringInfo in pairs(stringsToBeLocalized) do
if typeof(stringInfo) == "table" then
if typeof(stringInfo[1]) == "string" then
local success, result = pcall(function()
return localization:Format(stringInfo[1], stringInfo)
end)
ArgCheck.isEqual(success, true, string.format(
"LocalizationConsumer finding value for translation key[%s]: %s", stringName, stringInfo[1]))
localizedStrings[stringName] = success and result or ""
else
error(string.format("%s[1] in stringsToBeLocalized must be a string, got %s instead",
stringName, typeof(stringInfo[1])))
end
elseif typeof(stringInfo) == "string" then
local success, result = pcall(function()
return localization:Format(stringInfo)
end)
ArgCheck.isEqual(success, true, string.format(
"LocalizationConsumer finding value for translation key[%s]: %s", stringName, stringInfo))
localizedStrings[stringName] = success and result or ""
else
error(string.format("%s in stringsToBeLocalized must be a string or table, got %s instead",
stringName, typeof(stringInfo)))
end
end
if settings():GetFFlag("AppBridgeStartupController") then
return render(localizedStrings)
else
return Roact.createElement(ExternalEventConnection, {
event = LocalizationService:GetPropertyChangedSignal("RobloxLocaleId"),
callback = self.updateLocalization,
}, {
Component = render(localizedStrings),
})
end
end
return LocalizationConsumer
@@ -0,0 +1,4 @@
return function()
itSKIP("SOC-6353 - These unit tests need to be moved from LuaApp", function()
end)
end
@@ -0,0 +1,142 @@
--[[
Contains all of the loaded translations and provides methods to translate
keys and parameters to strings.
LocalizationContext doesn't handle loading of specific languages, but does
recommend what languages should be loaded (if available).
To create a new LocalizationContext:
local currentLanguage = LocalizationService.RobloxLocaleId
local languages = LocalizationContext.getRelevantLanguages(currentLanguage)
local translations = {}
-- Use the list of languages to load a set of translation tables here.
-- A translation table is just a map from key to the translated string.
-- `translations` is a map from language to translation tables.
local context = LocalizationContext.new(translations)
-- Get a string that doesn't require parameters
context:getString(currentLanguage, "SOME_KEY")
-- Passing parameters:
context:getString(currentLanguage, "FANCY_KEY", {
apples = 5,
})
Additional languages can be added after the LocalizationContext is created
by calling `addTranslations`. Whenever the user's language changes, call
`getRelevantLanguages` to get a new list of languages to load, load them,
then call `addTranslations` to merge them in with the existing tables.
]]
--[[
Finds the base language code for the given language, if there is one.
We assume:
* Language codes are of the form LANGUAGE or LANGUAGE_COUNTRY
* LANGUAGE_COUNTRY is more specific than LANGUAGE
]]
local function getBaseLanguage(languageName)
return languageName:match("^(%w+)[-_]")
end
local LocalizationContext = {}
LocalizationContext.__index = LocalizationContext
function LocalizationContext.new(translations)
local self = {
_translations = translations,
}
setmetatable(self, LocalizationContext)
return self
end
--[[
Add translations to an existing LocalizationContext, such as when a user
switches languages while the app is running.
]]
function LocalizationContext:addTranslations(translations)
self._translations = translations
end
--[[
Yields a list of languages relevant to the current user.
When the user's language changes, query this value, load those translations,
and add them to the LocalizationContext using addTranslations.
]]
function LocalizationContext.getRelevantLanguages(primaryLanguage)
local languages = {}
-- Load the language itself if available.
table.insert(languages, primaryLanguage)
-- If there's a fallback for our current language, load that as well.
local fallbackLanguage = getBaseLanguage(primaryLanguage)
if fallbackLanguage then
table.insert(languages, fallbackLanguage)
end
-- We should always load English, as it should contain every valid key.
table.insert(languages, "en-us")
return languages
end
function LocalizationContext:_getSourceString(language, key)
local translationTable = self._translations[language]
if not translationTable then
return nil
end
return translationTable[key]
end
--[[
Translate a key with a set of arguments into the given language.
`language` must be explicitly provided
]]
function LocalizationContext:getString(language, key, parameters)
local exactValue = self:_getSourceString(language, key)
local baseLanguage = getBaseLanguage(language)
local baseLanguageValue
if baseLanguage then
baseLanguageValue = self:_getSourceString(baseLanguage, key)
end
local englishValue = self:_getSourceString("en-us", key)
-- We try to find source strings in descending priority here:
local sourceString = exactValue or baseLanguageValue or englishValue
-- Missing translations are considered a developer error, so we throw here.
if not sourceString then
local message = (
"Couldn't find value for translation key %q!\n" ..
"Tried these languages: %s, %s, %s"
):format(
key,
language, baseLanguage, "en-us"
)
error(message, 2)
end
-- If we have parameters to insert into the string, put them in!
-- We don't check for missing parameters, should we in the future?
if parameters then
return (sourceString:gsub("{(.-)}", parameters))
else
return sourceString
end
end
return LocalizationContext
@@ -0,0 +1,66 @@
return function()
local LocalizationContext = require(script.Parent.LocalizationContext)
it("should pull from the correct language if available", function()
local context = LocalizationContext.new({
["es-mx"] = {
["SomeKey"] = "Foo",
},
["es"] = {
["SomeKey"] = "Bar",
},
["en-us"] = {
["SomeKey"] = "Baz",
},
})
expect(context:getString("es-mx", "SomeKey")).to.equal("Foo")
expect(context:getString("es", "SomeKey")).to.equal("Bar")
expect(context:getString("en", "SomeKey")).to.equal("Baz")
end)
it("should fall through to a language's base language", function()
local context = LocalizationContext.new({
["es-mx"] = {},
["es"] = {
["SomeKey"] = "Bar",
},
["en"] = {
["SomeKey"] = "Baz",
},
})
expect(context:getString("es-mx", "SomeKey")).to.equal("Bar")
expect(context:getString("es", "SomeKey")).to.equal("Bar")
expect(context:getString("en", "SomeKey")).to.equal("Baz")
end)
it("should fall through to English if keys are missing in each table", function()
local context = LocalizationContext.new({
["es-mx"] = {},
["es"] = {},
["en-us"] = {
["SomeKey"] = "Baz",
},
})
expect(context:getString("es-mx", "SomeKey")).to.equal("Baz")
expect(context:getString("es", "SomeKey")).to.equal("Baz")
expect(context:getString("en_us", "SomeKey")).to.equal("Baz")
end)
it("should replace formatting identifiers of the form {name}", function()
local context = LocalizationContext.new({
["en-us"] = {
["SomeKey"] = "{greeting}, {target}!",
},
})
local value = context:getString("en-us", "SomeKey", {
greeting = "Hello",
target = "world",
})
expect(value).to.equal("Hello, world!")
end)
end
@@ -0,0 +1,4 @@
local CorePackages = game:GetService("CorePackages")
local Symbol = require(CorePackages.Symbol)
return Symbol.named("Localization")
@@ -0,0 +1,19 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local LocalizationKey = require(CorePackages.Localization.LocalizationKey)
local LocalizationProvider = Roact.Component:extend("LocalizationProvider")
function LocalizationProvider:init(props)
local localization = props.localization
self._context[LocalizationKey] = {
localization = localization
}
end
function LocalizationProvider:render()
return Roact.oneChild(self.props[Roact.Children])
end
return LocalizationProvider
@@ -0,0 +1,299 @@
-- Example locale-sensitive number formatting:
-- https://docs.oracle.com/cd/E19455-01/806-0169/overview-9/index.html
--[[
Locale specification:
[DECIMAL_SEPARATOR] = string for decimal point, if needed
[GROUP_DELIMITER] = string for groupings of numbers left of the decimal
List section = abbreviations for language, in increasing order
Missing features in this code:
- No support for differences in number of digits per GROUP_DELIMITER.
Some Chinese dialects group by 10000 instead of 1000.
- No support for variable differences in number of digits per GROUP_DELIMITER.
Indian natural language groups the first 3 to left of decimal, then every 2 after that.
See https://en.wikipedia.org/wiki/Decimal_separator#Digit_grouping
]]
local CorePackages = game:GetService("CorePackages")
local Logging = require(CorePackages.Logging)
local RoundingBehaviour = require(script.Parent.RoundingBehaviour)
local localeInfos = {}
local DEFAULT_LOCALE = "en-us"
-- Separator aliases to help avoid spelling errors
local DECIMAL_SEPARATOR = "decimalSeparator"
local GROUP_DELIMITER = "groupDelimiter"
localeInfos["en-us"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",",
{ 1, "", },
{ 1e3, "K", },
{ 1e6, "M", },
{ 1e9, "B", },
}
localeInfos["es-es"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = ".",
{ 1, "", },
{ 1e3, " mil", },
{ 1e6, " M", },
}
localeInfos["fr-fr"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = " ",
{ 1, "", },
{ 1e3, " k", },
{ 1e6, " M", },
{ 1e9, " Md", },
}
localeInfos["de-de"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = " ",
{ 1, "", },
{ 1e3, " Tsd.", },
{ 1e6, " Mio.", },
{ 1e9, " Mrd.", },
}
localeInfos["pt-br"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = ".",
{ 1, "", },
{ 1e3, " mil", },
{ 1e6, " mi", },
{ 1e9, " bi", },
}
localeInfos["zh-cn"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",", -- Chinese commonly uses 3 digit groupings, despite 10000s rule
{ 1, "", },
{ 1e3, "", },
{ 1e4, "", },
{ 1e8, "亿", },
}
localeInfos["zh-tw"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",", -- Chinese commonly uses 3 digit groupings, despite 10000s rule
{ 1, "", },
{ 1e3, "", },
{ 1e4, "", },
{ 1e8, "", },
}
localeInfos["ko-kr"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",",
{ 1, "", },
{ 1e3, "", },
{ 1e4, "", },
{ 1e8, "", },
}
localeInfos["ja-jp"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",",
{ 1, "", },
{ 1e3, "", },
{ 1e4, "", },
{ 1e8, "", },
}
localeInfos["it-it"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = " ",
{ 1, "", },
{ 1e3, " mila", },
{ 1e6, " Mln", },
{ 1e9, " Mld", },
}
localeInfos["ru-ru"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = ".",
{ 1, "", },
{ 1e3, " тыс", },
{ 1e6, " млн", },
{ 1e9, " млрд", },
}
localeInfos["id-id"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = ".",
{ 1, "", },
{ 1e3, " rb", },
{ 1e6, " jt", },
{ 1e9, " M", },
}
localeInfos["vi-vn"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = " ",
{ 1, "", },
{ 1e3, " N", },
{ 1e6, " Tr", },
{ 1e9, " T", },
}
localeInfos["th-th"] = {
[DECIMAL_SEPARATOR] = ".",
[GROUP_DELIMITER] = ",",
{ 1, "", },
{ 1e3, "", },
{ 1e4, "", },
{ 1e5, "", },
{ 1e6, "", },
}
localeInfos["tr-tr"] = {
[DECIMAL_SEPARATOR] = ",",
[GROUP_DELIMITER] = ".",
{ 1, "", },
{ 1e3, " B", },
{ 1e6, " Mn", },
{ 1e9, " Mr", },
}
-- Aliases for languages that use the same mappings.
localeInfos["en-gb"] = localeInfos["en-us"]
localeInfos["es-mx"] = localeInfos["es-es"]
local function findDecimalPointIndex(numberStr)
return string.find(numberStr, "%.") or #numberStr + 1
end
-- Find the base 10 offset needed to make 0.1 <= abs(number) < 1
local function findDecimalOffset(number)
if number == 0 then
return 0
end
local offsetToOnesRange = math.floor(math.log10(math.abs(number)))
return -(offsetToOnesRange + 1) -- Offset one more (or less) digit
end
local function roundToSignificantDigits(number, significantDigits, roundingBehaviour)
local offset = findDecimalOffset(number)
local multiplier = 10^(significantDigits + offset)
local significand
if roundingBehaviour == RoundingBehaviour.Truncate then
significand = math.modf(number * multiplier)
else
significand = math.floor(number * multiplier + 0.5)
end
return significand / multiplier;
end
local function addGroupDelimiters(numberStr, delimiter)
local formatted = numberStr
local delimiterSubStr = string.format("%%1%s%%2", delimiter)
while true do
local lFormatted, k = string.gsub(formatted, "^(-?%d+)(%d%d%d)", delimiterSubStr)
formatted = lFormatted
if k == 0 then
break
end
end
return formatted
end
local function findDenominationEntry(localeInfo, number, roundingBehaviour)
local denominationEntry = localeInfo[1] -- Default to base denominations
local absOfNumber = math.abs(number)
for i = #localeInfo, 2, -1 do
local entry = localeInfo[i]
local baseValue
if roundingBehaviour == RoundingBehaviour.Truncate then
baseValue = entry[1]
else
baseValue = entry[1] - (localeInfo[i - 1][1]) / 2
end
if baseValue <= absOfNumber then
denominationEntry = entry
break
end
end
return denominationEntry
end
local NumberLocalization = { }
function NumberLocalization.localize(number, locale)
if number == 0 then
return "0"
end
local localeInfo = localeInfos[locale]
if not localeInfo then
localeInfo = localeInfos[DEFAULT_LOCALE]
Logging.warn(string.format("Warning: Locale not found: '%s', reverting to '%s' instead.",
tostring(locale), DEFAULT_LOCALE))
end
if localeInfo.groupDelimiter then
return addGroupDelimiters(number, localeInfo.groupDelimiter)
end
return number
end
function NumberLocalization.abbreviate(number, locale, roundingBehaviour)
if number == 0 then
return "0"
end
if roundingBehaviour == nil then
roundingBehaviour = RoundingBehaviour.RoundToClosest
end
local localeInfo = localeInfos[locale]
if not localeInfo then
localeInfo = localeInfos[DEFAULT_LOCALE]
Logging.warn(string.format("Warning: Locale not found: '%s', reverting to '%s' instead.",
tostring(locale), DEFAULT_LOCALE))
end
-- select which denomination we are going to use
local denominationEntry = findDenominationEntry(localeInfo, number, roundingBehaviour)
local baseValue = denominationEntry[1]
local symbol = denominationEntry[2]
-- Round to required significant digits
local significantQuotient = roundToSignificantDigits(number / baseValue, 3, roundingBehaviour)
-- trim to 1 decimal point
local trimmedQuotient
if roundingBehaviour == RoundingBehaviour.Truncate then
trimmedQuotient = math.modf(significantQuotient * 10) / 10
else
trimmedQuotient = math.floor(significantQuotient * 10 + 0.5) / 10
end
local trimmedQuotientString = tostring(trimmedQuotient)
-- Split the string into integer and fraction parts
local decimalPointIndex = findDecimalPointIndex(trimmedQuotientString)
local integerPart = string.sub(trimmedQuotientString, 1, decimalPointIndex - 1)
local fractionPart = string.sub(trimmedQuotientString, decimalPointIndex + 1, #trimmedQuotientString)
-- Add group delimiters to integer part
if localeInfo.groupDelimiter then
integerPart = addGroupDelimiters(integerPart, localeInfo.groupDelimiter)
end
if #fractionPart > 0 then
return integerPart .. localeInfo.decimalSeparator .. fractionPart .. symbol
else
return integerPart .. symbol
end
end
return NumberLocalization
@@ -0,0 +1,110 @@
return function()
local CorePackages = game:GetService("CorePackages")
local Logging = require(CorePackages.Logging)
local NumberLocalization = require(CorePackages.Localization.NumberLocalization)
local RoundingBehaviour = require(script.Parent.RoundingBehaviour)
local function checkLocale(locale, responseMapping)
for input, output in pairs(responseMapping) do
expect(NumberLocalization.localize(input, locale)).to.equal(output)
end
end
local function checkValid_en_zh(locale)
checkLocale(locale, {
[0] = "0",
[1] = "1",
[25] = "25",
[364] = "364",
[4120] = "4,120",
[57860] = "57,860",
[624390] = "624,390",
[7857000] = "7,857,000",
[-12345678] = "-12,345,678",
[23987.45678] = "23,987.45678",
[-12.3456] = "-12.3456",
[-23987.45678] = "-23,987.45678",
})
end
describe("NumberLocalization.localize", function()
it("should default to en-us when locale is not recognized", function()
local logs = Logging.capture(function()
checkValid_en_zh("bad_locale")
end)
expect(string.match(logs.warnings[1], "^Warning: Locale not found:") ~= nil).to.equal(true)
end)
it("should default to en-us when locale is nil", function()
local logs = Logging.capture(function()
checkValid_en_zh(nil)
end)
expect(string.match(logs.warnings[1], "^Warning: Locale not found:") ~= nil).to.equal(true)
end)
it("should default to en-us when locale is empty", function()
local logs = Logging.capture(function()
checkValid_en_zh("")
end)
expect(string.match(logs.warnings[1], "^Warning: Locale not found:") ~= nil).to.equal(true)
end)
it("should localize correctly. (en-us)", function()
checkValid_en_zh("en-us")
end)
it("should localize correctly. (en-gb)", function()
checkValid_en_zh("en-gb")
end)
it("should localize correctly. (zh-cn)", function()
checkValid_en_zh("zh-cn")
end)
it("should localize correctly. (zh-tw)", function()
checkValid_en_zh("zh-tw")
end)
end)
describe("NumberLocalization.abbreviate", function()
it("should round towards zero when using RoundingBehaviour.Truncate", function()
local roundToZeroMap = {
[0] = "0",
[1] = "1",
[25] = "25",
[364] = "364",
[4120] = "4.1K",
[57860] = "57.8K",
[624390] = "624K",
[999999] = "999K",
[7857000] = "7.8M",
[8e7] = "80M",
[9e8] = "900M",
[1e9] = "1B",
[1e12] = "1,000B",
[-0] = "0",
[-1] = "-1",
[-25] = "-25",
[-364] = "-364",
[-4120] = "-4.1K",
[-57860] = "-57.8K",
[-624390] = "-624K",
[-999999] = "-999K",
[-7857000] = "-7.8M",
[-8e7] = "-80M",
[-9e8] = "-900M",
[-1e9] = "-1B",
[-1e12] = "-1,000B",
[1.1] = "1.1",
[1499.99] = "1.4K",
[-1.1] = "-1.1",
[-1499.99] = "-1.4K",
}
for input, output in pairs(roundToZeroMap) do
expect(NumberLocalization.abbreviate(input, "en-us", RoundingBehaviour.Truncate)).to.equal(output)
end
end)
end)
end
@@ -0,0 +1,7 @@
local CorePackages = game:GetService("CorePackages")
local enumerate = require(CorePackages.enumerate)
return enumerate("RoundingBehaviour", {
"RoundToClosest",
"Truncate",
})
@@ -0,0 +1,19 @@
local CorePackages = game:GetService("CorePackages")
local Roact = require(CorePackages.Roact)
local ArgCheck = require(CorePackages.ArgCheck)
local LocalizationConsumer = require(CorePackages.Localization.LocalizationConsumer)
local function withLocalization(stringsToBeLocalized)
ArgCheck.isType(stringsToBeLocalized, "table", "stringsToBeLocalized passed to withLocalization()")
return function(render)
ArgCheck.isType(render, "function", "render passed to withLocalization()")
return Roact.createElement(LocalizationConsumer, {
render = render,
stringsToBeLocalized = stringsToBeLocalized,
})
end
end
return withLocalization
@@ -0,0 +1,4 @@
return function()
itSKIP("SOC-6353 - These unit tests need to be moved from LuaApp", function()
end)
end