chore: replace positional translation parameters with named interpolation
Proposed changes (including videos or screenshots)
Continues the migration from the legacy i18next-sprintf-postprocessor (%s) to i18next named interpolation ({{param}}).
en contained 75 keys with %s. This PR resolves 50 of them: 29 deleted, 19 converted, 2 that were rendering a raw placeholder to users.
No changeset: everything here either has no user-visible effect or fixes a string that was already displaying a raw %s, and the affected keys are internal command responses.
Deleted — 29 keys with no reference anywhere
Verified by searching each literal key across apps/, packages/ and ee/, all file types, and by checking for dynamically constructed keys. Removed from all 58 locale files rather than en alone, because wipe-extra-keys fails CI on any non-en key that en lacks.
Created_at_s_by_s, Created_at_s_by_s_triggered_by_s, Duplicate_archived_private_group_name, Duplicate_channel_name, Duplicate_private_group_name, Invalid_Export_File, Invalid_notification_setting_s, Invalid_room_name, Invalid_setting_s, IssueLinks_LinkTemplate_Description, Login_with, N_new_messages, Post_to_s_as_s, Purchase_for_price, S_new_messages, S_new_messages_since_s, Showing_archived_results, Showing_results, Snippet_Added, Use_service_avatar, User_has_been_muted_in_s, WebRTC_direct_audio_call_from_%s, WebRTC_direct_video_call_from_%s, WebRTC_group_audio_call_from_%s, WebRTC_group_video_call_from_%s, WebRTC_monitor_call_from_%s, conversation_with_s, n_messages, since_creation
The five WebRTC_* keys were orphaned by 31a654d4be (chore!: Remove MeetPage and webRTC (#36671)), which removed the feature and its call sites but left the strings behind.
Also confirmed nothing outside this repo reads them. These keys are served over HTTP at GET /i18n/<lng>.json, but the only consumers of that route are the web client itself (TranslationProvider, loadPath: 'i18n/{{lng}}.json') and one e2e test. The mobile app bundles its own separate 1,075-key set and references neither the route nor any of these keys.
Modified — 21 keys converted to named interpolation
| Keys | Params |
|---|---|
Channel_Archived, Channel_Unarchived, Channel_already_Unarchived, Channel_created, Channel_doesnt_exist, Duplicate_archived_channel_name | {{channelName}} |
Edit_previous_message, Mark_all_as_read, Move_beginning_message, Move_end_message, New_line_message_compose_input, Open_channel_user_search | {{shortcut}} |
SlackBridge_error | {{channelName}}, {{errorMessage}} |
SlackBridge_finish | {{channelName}} |
SlackBridge_start | {{username}}, {{channelName}} |
User_doesnt_exist, Username_doesnt_exist, Username_is_already_in_here, Username_invalid | {{username}} |
error-logged-user-not-in-room | {{roomName}} |
Welcome | {{user}} |
Names follow the conventions already established by #41667. Welcome needed no code change at all, because both call sites were already passing named { user }.
The two SlackBridge_error call sites also needed interpolation: { escapeValue: false }. errorMessage is arbitrary text and the server i18n instance escapes by default (only the client sets escapeValue: false), so without the opt-out an error containing < or & would render as an HTML entity where sprintf never escaped anything.
Left as-is — 25 keys, with reasons
| Count | Keys | Reason |
|---|---|---|
| 19 | Channel_already_exist, Custom_oauth_helper, Do_you_want_to_change_to_s_question, Mail_Message_Invalid_emails, Mail_Messages_Subject, Max_length_is, Min_length_is, Prune_Warning_after, Prune_Warning_all, Prune_Warning_before, Prune_Warning_between, Showing_results_of, The_setting_s_is_configured_to_s_and_you_are_accessing_from_s, The_user_s_will_be_removed_from_role_s, The_user_will_be_removed_from_s, The_user_wont_be_able_to_type_in_s, User_has_been_removed_from_s, Users_Table_Generic_No_users, if_they_are_from | Already converted in open PR #41667. Left alone to avoid a conflict. |
| 4 | New_version_available_(s), Sending_your_mail_to_s, The_server_will_restart_in_s_seconds, Your_push_was_sent_to_s_devices | Blocked on a wire-format change, see below. |
| 1 | Alias_Format_Description | The %s is literal documentation text shown to the admin, not a placeholder. Must never be converted. |
| 1 | MongoDB_version_s_is_deprecated_please_upgrade_your_installation | Its only reference is commented out in serverRunning.ts. Held back from the delete batch pending a decision on that dead code. |
The four blocked keys travel as a translation key plus a positional array, and are translated client-side rather than server-side:
{ message, params }returned from Meteor methods (restartServer,sendSMTPTestEmail,push_test) and from REST/v1/push.test, consumed byActionInputBase;textArgumentson banner documents persisted inusers.banners, consumed byuseUserBanners.
Named params need an object instead of an array, which means changing a public REST response shape and having a story for banner rows already in the database. That deserves its own ticket. It is also why i18next-sprintf-postprocessor cannot be dropped yet: even at zero %s keys, those readers still call it.
Bugs found and fixed along the way
- Three slash commands crashed rather than rendering wrong. A malformed placeholder throws
SyntaxError: [sprintf] unexpected placeholderinsidei18n.t(). Ondevelop,/archiveroomand/unarchiveroomthrow on a Romanian-language server,/unarchiveroomthrows on Serbian, and/slackbridge-importthrows on Belarusian, instead of replying. Welcomedisplayed a literal%s. Both call sites passed named{ user }whileenstill held%s, so the welcome system message showed%sinstead of the username.Username_invaliddisplayed a literal%s. The call site passed no argument at all. Korean was worse:&s, an ampersand typo that no%s-based tooling could ever match./helpshowed no shortcut at all in 24 languages, which had dropped the placeholder fromMark_all_as_readentirely.i18n.t(key, options, user.language)silently ignored the language. i18next'sTFunctionhas no third-argument overload, so all four/slackbridge-importmessages always rendered in the server default language regardless of the user's setting. The file is@ts-nocheck, so TypeScript never flagged it.- The i18n checker under-reported by 8 keys.
find-sprintf-paramsdeclared/%s/goutside its per-key loop, solastIndexpersisted across.exec()calls on different strings and keys were skipped after any match. It reported 67 against a ground truth of 75, hidingMove_end_message,SlackBridge_finishandUsername_is_already_in_hereamong others, all of which needed conversion. The regex is now gone in favour oftranslation.includes('%s'), which has no state to leak. - Unsubstituted placeholders across many commands.
zh-HKused fullwidth%son 18 of these keys, with further mangled variants inro,sr,ku,mn,be-BYandko.
Issue(s)
CORE-2506
Steps to test or reproduce
- Set Administration → Settings → General → Language to Romanian, then run
/archiveroomin any channel. Ondevelopthis throws instead of replying. With this PR it postsCanal cu numele #<channel> a fost arhivată cu succes. Same for/unarchiveroom, and for/unarchiveroomin Serbian. - Set the language to Chinese (Hong Kong) and run
/help. Ondevelopevery line shows a literal%s; with this PR each shows its real shortcut. - Set the language to German (or any of the 24 affected) and run
/help. Ondevelopthe "mark all as read" line has no shortcut at all; with this PR it showsShift (or Ctrl) + ESC. - Run
/invite @nonexistentuser, then/kick @nonexistentuser,/mute @nonexistentuser,/ban @nonexistentuser. Each names the user instead of printing%s. - Trigger the welcome system message in a room. It shows the username instead of
%s. - Reach the username registration screen and submit an invalid username. The error names the username instead of printing
%s. - Expected result: no screen displays a raw
%s, and no command errors out.
Regression check: yarn workspace @rocket.chat/i18n lint passes. Its missing-placeholders task enforces that every locale's placeholders match en exactly, so any locale the migration missed fails CI rather than shipping silently.
Further comments
The diff spans 40 languages, so every conversion was verified mechanically rather than by review. A harness rendered each key's old value through the real i18next-sprintf-postprocessor and its new value through real i18next named interpolation, with identical arguments. A behaviour-preserving conversion must produce byte-identical output.
| Rows | |
|---|---|
| Total | 1175 |
| Identical | 1125 |
| Different | 45 |
| Old value crashed sprintf | 5 |
Unresolved {{…}} or %s left in output | 0 |
The 50 non-identical rows were then asserted programmatically to be exactly the set of locales fixed by hand: no unexpected differences, and nothing claimed as fixed that was actually untouched. All 45 differing rows are strict improvements over a previously visible %s, % с or &s.
Where a locale's placeholder was mangled, the rule was to relocate the placeholder and keep every existing word, never to author a translation. One deliberate exception: 24 locales had dropped the Mark_all_as_read shortcut prefix entirely, so it was restored using the exact structure of the four locales (en, hi-IN, nb, nn) that still had it correct.
One thing left for Lingohub: ku on SlackBridge_error now reads …çewtiyek çêbû general: connection refused and grammatically wants the preposition li before the channel name. Left alone rather than invent a Kurdish word.
ESLint on all 16 touched source files reports 5 warnings, every one byte-identical to the pre-change baseline. No new lint debt.
Summary by CodeRabbit
-
Bug Fixes
- Improved translated error and status messages so usernames, channel names, shortcuts, and error details display correctly across commands and integrations.
- Corrected invalid-username feedback to include the submitted username.
-
Localization
- Updated numerous languages with consistent named placeholders for dynamic content.
- Removed outdated translation entries and legacy message variants.
- Improved consistency of localized Slack Bridge, welcome, channel, and user-related messages.