From 6d9a32d28bab192eb437ec9b67d06621e7e47e54 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:02:37 +0100 Subject: [PATCH] Show localised names of mod settings in the web UI (#1181) The mod pack page only looked up mod-setting-name., so settings with a localised_name showed the internal name, and rich text tags in the locale showed up as raw text. Resolve localised_name and localised_description from the exported settings prototypes against the exported locale, falling back to the mod-setting-name/mod-setting-description keys and then the internal name. The resolver handles parameters, the "" and "?" keys and __ITEM__/__ENTITY__/__TILE__/__FLUID__ references. Rich text formatting tags are removed and prototype icons are replaced with their name. Fixes #768 Co-authored-by: Claude Opus 5.5 --- .../web_ui/src/components/ModPackViewPage.tsx | 14 +- packages/web_ui/src/util/locale.ts | 160 ++++++++++++++++++ packages/web_ui/src/util/mod_settings.ts | 4 + test/web_ui/locale.js | 113 +++++++++++++ test/web_ui/mod_settings.js | 3 + 5 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 packages/web_ui/src/util/locale.ts create mode 100644 test/web_ui/locale.js diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 933f6f4b..3bd8558f 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -23,6 +23,7 @@ import { useMods } from "../model/mods"; import { useClipboard } from "../util/clipboard"; import { modSettingFields, ModSettingField, ModSettingScope } from "../util/mod_settings"; import notify, { notifyErrorHandler } from "../util/notify"; +import { modSettingDescription, modSettingName, modSettingValueName } from "../util/locale"; import useResourceState from "../util/useResourceState"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; @@ -901,6 +902,8 @@ type SettingsTableFieldProps = { type: string; scope: ModSettingScope; allowedValues: any; + localisedName?: lib.FactorioLocalisedString; + localisedDescription?: lib.FactorioLocalisedString; locale: Map; changed: boolean; /** Saved value of the setting, used when the field is created and when the form is reset */ @@ -917,6 +920,7 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => { const isColor = props.type === "color-setting"; const isNumber = ["int-setting", "double-setting"].includes(props.type); const isSelect = Boolean(props.allowedValues); + const prototype = { name, localised_name: props.localisedName, localised_description: props.localisedDescription }; let input; if (isBoolean) { input = ; @@ -924,22 +928,22 @@ const SettingsTableField = memo((props: SettingsTableFieldProps) => { const PartialInputColor = InputColor as () => React.JSX.Element; input = ; } else if (isSelect) { - const options = Object.values(props.allowedValues).map(value => ({ + const options = Object.values(props.allowedValues as (string | number)[]).map(value => ({ value, - label: props.locale.get(`string-mod-setting.${name}-${value}`) || value, + label: modSettingValueName(props.locale, name, value), })); input = ; } - const label = props.locale.get(`mod-setting-name.${name}`) || name; + const label = modSettingName(props.locale, prototype); const change: ModChange = { type: "settings.delete", scope: props.scope, name, value: { value: props.initialValue! }, }; return {label} : label} - tooltip={props.locale.get(`mod-setting-description.${name}`)} + tooltip={modSettingDescription(props.locale, prototype)} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} className={props.changed ? "changed" : ""} @@ -999,6 +1003,8 @@ function SettingsTable(props: SettingsTableProps) { type={setting.type} scope={scope} allowedValues={setting.allowed_values} + localisedName={setting.localised_name} + localisedDescription={setting.localised_description} locale={props.locale} changed={hasChange(props.changes, { type: "settings.set", scope, name: setting.name })} initialValue={props.savedModPack.settings[scope].get(setting.name)?.value} diff --git a/packages/web_ui/src/util/locale.ts b/packages/web_ui/src/util/locale.ts new file mode 100644 index 00000000..16181c0e --- /dev/null +++ b/packages/web_ui/src/util/locale.ts @@ -0,0 +1,160 @@ +import type { FactorioLocalisedString } from "@clusterio/lib"; + +type Locale = Map; + +const maxDepth = 20; +const prototypeReferences: Record = { + ENTITY: "entity-name", + ITEM: "item-name", + TILE: "tile-name", + FLUID: "fluid-name", +}; + +function substitute(locale: Locale, template: string, parameters: string[]) { + return template.replace(/__([A-Z_]+)__([^_\s][^\s]*?)__|__(\d+)__/g, (match, kind, name, index) => { + if (index !== undefined) { + return parameters[Number(index) - 1] ?? match; + } + const category = prototypeReferences[kind]; + return category ? locale.get(`${category}.${name}`) ?? name : match; + }); +} + +function resolve(locale: Locale, value: unknown, depth: number): string | undefined { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (!Array.isArray(value) || typeof value[0] !== "string" || depth > maxDepth) { + return undefined; + } + const [key, ...rest] = value; + if (key === "?") { + for (const alternative of rest) { + const result = resolve(locale, alternative, depth + 1); + if (result !== undefined) { + return result; + } + } + return undefined; + } + const parameters: string[] = []; + for (const parameter of rest) { + const result = resolve(locale, parameter, depth + 1); + if (result === undefined) { + return undefined; + } + parameters.push(result); + } + if (key === "") { + return parameters.join(""); + } + const template = locale.get(key); + return template === undefined ? undefined : substitute(locale, template, parameters); +} + +/** + * Resolve a Factorio LocalisedString against exported locale. + * + * @param locale - Flattened locale from the mod pack export. + * @param value - Localised string to resolve. + * @returns the resolved text, or undefined if a key used is missing from the locale. + * @see https://lua-api.factorio.com/stable/types/LocalisedString.html + */ +export function resolveLocalisedString(locale: Locale, value: FactorioLocalisedString) { + return resolve(locale, value, 0); +} + +const iconTags: Record = { + "item": "item-name", + "entity": "entity-name", + "fluid": "fluid-name", + "tile": "tile-name", + "recipe": "recipe-name", + "technology": "technology-name", + "item-group": "item-group-name", + "virtual-signal": "virtual-signal-name", + "space-location": "space-location-name", + "planet": "space-location-name", + "quality": "quality-name", +}; +const otherTags = [ + "color", "font", "img", "achievement", "gps", "special-item", "armor", "train", "train-stop", "tooltip", + "shortcut", "tip", +]; +const richTextPattern = new RegExp( + `\\[(?:/(?:color|font|tooltip)|(${[...Object.keys(iconTags), ...otherTags].join("|")})(?:=([^\\]]*))?)\\]`, + "g", +); + +/** + * Remove Factorio rich text tags from text, replacing icons of prototypes with their name. + * + * @param locale - Flattened locale from the mod pack export. + * @param text - Text that may contain rich text tags. + * @returns plain text + * @see https://wiki.factorio.com/Rich_text + */ +export function stripRichText(locale: Locale, text: string) { + return text.replace(richTextPattern, (_match, tag: string | undefined, value: string | undefined) => { + const category = tag ? iconTags[tag] : undefined; + if (!category || !value) { + return ""; + } + const name = value.split(",")[0]; + return locale.get(`${category}.${name}`) ?? name; + }).trim(); +} + +function localisedText(locale: Locale, localised: FactorioLocalisedString | undefined, key: string) { + let text: string | undefined; + if (localised !== undefined) { + text = resolveLocalisedString(locale, localised); + } + text ??= resolveLocalisedString(locale, [key]); + return text === undefined ? undefined : stripRichText(locale, text); +} + +type SettingPrototype = { + name: string, + localised_name?: FactorioLocalisedString, + localised_description?: FactorioLocalisedString, +}; + +/** + * Display name of a mod setting, falling back the same way Factorio does. + * + * @param locale - Flattened locale from the mod pack export. + * @param prototype - Mod setting prototype. + * @returns localised name, or the internal name if there is none. + */ +export function modSettingName(locale: Locale, prototype: SettingPrototype) { + return localisedText(locale, prototype.localised_name, `mod-setting-name.${prototype.name}`) || prototype.name; +} + +/** + * Description of a mod setting, falling back the same way Factorio does. + * + * @param locale - Flattened locale from the mod pack export. + * @param prototype - Mod setting prototype. + * @returns localised description, or undefined if there is none. + */ +export function modSettingDescription(locale: Locale, prototype: SettingPrototype) { + return localisedText( + locale, prototype.localised_description, `mod-setting-description.${prototype.name}` + ) || undefined; +} + +/** + * Display name of an allowed value of a string mod setting. + * + * @param locale - Flattened locale from the mod pack export. + * @param name - Internal name of the mod setting. + * @param value - Allowed value. + * @returns localised value, or the value itself if there is none. + */ +export function modSettingValueName(locale: Locale, name: string, value: string | number) { + return localisedText(locale, undefined, `string-mod-setting.${name}-${value}`) || String(value); +} diff --git a/packages/web_ui/src/util/mod_settings.ts b/packages/web_ui/src/util/mod_settings.ts index 0dc43ce5..e4361338 100644 --- a/packages/web_ui/src/util/mod_settings.ts +++ b/packages/web_ui/src/util/mod_settings.ts @@ -8,6 +8,8 @@ export type ModSettingField = { mod?: string; order?: string; allowed_values?: unknown; + localised_name?: lib.FactorioLocalisedString; + localised_description?: lib.FactorioLocalisedString; }; const settingTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"]; @@ -50,6 +52,8 @@ export function modSettingFields( mod: p.mod as string, order: p.order, allowed_values: p.allowed_values, + localised_name: p.localised_name, + localised_description: p.localised_description, })) .sort((a, b) => strcmp(a.mod, b.mod) || strcmp(a.order || "", b.order || "") || strcmp(a.name, b.name)) ; diff --git a/test/web_ui/locale.js b/test/web_ui/locale.js new file mode 100644 index 00000000..fd9add2c --- /dev/null +++ b/test/web_ui/locale.js @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; + +import { + modSettingDescription, modSettingName, modSettingValueName, resolveLocalisedString, stripRichText, +} from "../../packages/web_ui/src/util/locale.ts"; + +describe("web_ui/util/locale", function() { + const locale = new Map([ + ["phi-cl.combine", "__1__ __2__"], + ["mod-setting-name-prefix.PHI-MB", "[color=255,254,42]MB[/color]"], + ["mod-setting-name.PHI-MB", "[color=255,254,42]Enable better entity[/color]"], + ["mod-setting-name.plain", "Plain setting"], + ["mod-setting-description.plain", "Plain description"], + ["string-mod-setting.mode-fast", "[font=default-bold]Fast[/font]"], + ["item-name.iron-plate", "Iron plate"], + ["entity-name.inserter", "Inserter"], + ["uses", "Uses __ITEM__iron-plate__ and __ENTITY__inserter__"], + ["greeting", "Hello __1__"], + ]); + + describe("resolveLocalisedString()", function() { + it("should return plain strings and numbers as is", function() { + assert.equal(resolveLocalisedString(locale, "text"), "text"); + assert.equal(resolveLocalisedString(locale, 5), "5"); + }); + it("should substitute parameters", function() { + assert.equal(resolveLocalisedString(locale, ["greeting", "world"]), "Hello world"); + assert.equal(resolveLocalisedString(locale, ["greeting", ["greeting", 2]]), "Hello Hello 2"); + }); + it("should leave parameters that were not given", function() { + assert.equal(resolveLocalisedString(locale, ["greeting"]), "Hello __1__"); + }); + it("should substitute prototype names", function() { + assert.equal(resolveLocalisedString(locale, ["uses"]), "Uses Iron plate and Inserter"); + }); + it("should concatenate with an empty key", function() { + assert.equal(resolveLocalisedString(locale, ["", "a", ["greeting", "b"], 3]), "aHello b3"); + }); + it("should use the first alternative that resolves with a ? key", function() { + assert.equal(resolveLocalisedString(locale, ["?", ["missing"], ["greeting", "x"], "y"]), "Hello x"); + assert.equal(resolveLocalisedString(locale, ["?", ["missing"]]), undefined); + }); + it("should return undefined if a key is missing", function() { + assert.equal(resolveLocalisedString(locale, ["missing"]), undefined); + assert.equal(resolveLocalisedString(locale, ["greeting", ["missing"]]), undefined); + }); + it("should return undefined for nesting that is too deep", function() { + let value = "end"; + for (let i = 0; i < 30; i++) { + value = ["", value]; + } + assert.equal(resolveLocalisedString(locale, value), undefined); + }); + }); + + describe("stripRichText()", function() { + it("should remove formatting tags", function() { + assert.equal(stripRichText(locale, "[color=red]A[/color] [font=default-bold]B[/font]"), "A B"); + }); + it("should replace prototype icons with their name", function() { + assert.equal(stripRichText(locale, "[item=iron-plate] plates"), "Iron plate plates"); + assert.equal(stripRichText(locale, "[item=iron-plate,quality=rare]"), "Iron plate"); + assert.equal(stripRichText(locale, "[fluid=unknown]"), "unknown"); + }); + it("should remove other icons", function() { + assert.equal(stripRichText(locale, "[img=utility/warning_icon] Warning"), "Warning"); + }); + it("should keep brackets that are not tags", function() { + assert.equal(stripRichText(locale, "[Beta] [x=1]"), "[Beta] [x=1]"); + }); + }); + + describe("modSettingName()", function() { + it("should resolve localised_name", function() { + const prototype = { + name: "PHI-MB", + localised_name: ["phi-cl.combine", ["mod-setting-name-prefix.PHI-MB"], ["mod-setting-name.PHI-MB"]], + }; + assert.equal(modSettingName(locale, prototype), "MB Enable better entity"); + }); + it("should fall back to the mod-setting-name key", function() { + assert.equal(modSettingName(locale, { name: "plain" }), "Plain setting"); + assert.equal(modSettingName(locale, { name: "plain", localised_name: ["missing"] }), "Plain setting"); + }); + it("should fall back to the internal name", function() { + assert.equal(modSettingName(locale, { name: "other" }), "other"); + assert.equal(modSettingName(new Map(), { name: "plain" }), "plain"); + }); + }); + + describe("modSettingDescription()", function() { + it("should resolve localised_description", function() { + const prototype = { name: "plain", localised_description: ["greeting", "desc"] }; + assert.equal(modSettingDescription(locale, prototype), "Hello desc"); + }); + it("should fall back to the mod-setting-description key", function() { + assert.equal(modSettingDescription(locale, { name: "plain" }), "Plain description"); + }); + it("should return undefined without a description", function() { + assert.equal(modSettingDescription(locale, { name: "other" }), undefined); + }); + }); + + describe("modSettingValueName()", function() { + it("should resolve the string-mod-setting key", function() { + assert.equal(modSettingValueName(locale, "mode", "fast"), "Fast"); + }); + it("should fall back to the value", function() { + assert.equal(modSettingValueName(locale, "mode", "slow"), "slow"); + assert.equal(modSettingValueName(locale, "mode", 3), "3"); + }); + }); +}); diff --git a/test/web_ui/mod_settings.js b/test/web_ui/mod_settings.js index fe533cb0..607e3b71 100644 --- a/test/web_ui/mod_settings.js +++ b/test/web_ui/mod_settings.js @@ -21,6 +21,7 @@ describe("web_ui/util/mod_settings", function() { "first": { type: "string-setting", name: "first", mod: "a", setting_type: "startup", default_value: "x", allowed_values: ["x", "y"], + localised_name: ["name.first"], localised_description: ["description.first"], }, }, "unrelated": { "gone-number": { type: "unrelated", name: "gone-number", setting_type: "startup" } }, @@ -30,6 +31,8 @@ describe("web_ui/util/mod_settings", function() { const { known, unknown } = modSettingFields(prototypes, "startup", stored); assert.deepEqual(known.map(field => field.name), ["first", "kept"]); assert.deepEqual(known[0].allowed_values, ["x", "y"]); + assert.deepEqual(known[0].localised_name, ["name.first"]); + assert.deepEqual(known[0].localised_description, ["description.first"]); assert.deepEqual( unknown.map(field => [field.name, field.type]), [["gone-color", "color-setting"], ["gone-number", "double-setting"], ["gone-string", "string-setting"]],