Просмотр исходного кода

Ensure that translation values are correctly escaped (#15252)

GitOrigin-RevId: 5a38b4c01921fd4d95dbdb7b9e756443fdb00b80
Alf Eaton 3 лет назад
Родитель
Сommit
0c81bccfca
61 измененных файлов с 774 добавлено и 218 удалено
  1. 425 200
      package-lock.json
  2. 4 0
      services/web/.eslintrc
  3. 0 2
      services/web/frontend/extracted-translations.json
  4. 2 0
      services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.jsx
  5. 2 0
      services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx
  6. 2 0
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx
  7. 2 0
      services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-error.jsx
  8. 6 0
      services/web/frontend/js/features/file-view/components/file-view-header.tsx
  9. 2 0
      services/web/frontend/js/features/group-management/components/group-members.tsx
  10. 10 0
      services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx
  11. 4 0
      services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-datetime.tsx
  12. 1 1
      services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts
  13. 4 0
      services/web/frontend/js/features/pdf-preview/components/compile-timeout-messages.tsx
  14. 2 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.jsx
  15. 4 0
      services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx
  16. 2 0
      services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx
  17. 2 0
      services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx
  18. 2 0
      services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx
  19. 2 0
      services/web/frontend/js/features/project-list/components/notifications/ads/latam-banner.tsx
  20. 4 0
      services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
  21. 10 0
      services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
  22. 2 0
      services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx
  23. 2 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx
  24. 2 0
      services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx
  25. 12 0
      services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx
  26. 2 0
      services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx
  27. 1 0
      services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx
  28. 6 6
      services/web/frontend/js/features/project-list/components/table/project-list-table.tsx
  29. 2 0
      services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx
  30. 2 0
      services/web/frontend/js/features/settings/components/emails/add-email.tsx
  31. 2 0
      services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx
  32. 4 0
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx
  33. 2 0
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx
  34. 4 0
      services/web/frontend/js/features/settings/components/emails/row.tsx
  35. 6 0
      services/web/frontend/js/features/settings/components/emails/sso-alert.tsx
  36. 2 0
      services/web/frontend/js/features/settings/components/leave/modal-form.tsx
  37. 2 0
      services/web/frontend/js/features/settings/components/managed-account-alert.tsx
  38. 2 0
      services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.jsx
  39. 2 0
      services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.jsx
  40. 1 0
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx
  41. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx
  42. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx
  43. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
  44. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx
  45. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/managed-publisher.tsx
  46. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx
  47. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  48. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
  49. 24 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
  50. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
  51. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
  52. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx
  53. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx
  54. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx
  55. 2 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx
  56. 4 0
      services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx
  57. 2 0
      services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx
  58. 4 0
      services/web/frontend/js/i18n.js
  59. 4 4
      services/web/package.json
  60. 4 4
      services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx
  61. 144 0
      services/web/test/frontend/infrastructure/i18n.spec.tsx

Разница между файлами не показана из-за своего большого размера
+ 425 - 200
package-lock.json


+ 4 - 0
services/web/.eslintrc

@@ -7,6 +7,7 @@
     "standard",
     "prettier"
   ],
+  "plugins": ["@overleaf"],
   "env": {
     "es2020": true
   },
@@ -113,6 +114,9 @@
       //
       "files": ["**/frontend/js/**/components/**/*.{js,jsx,ts,tsx}", "**/frontend/js/**/hooks/**/*.{js,jsx,ts,tsx}"],
       "rules": {
+        "@overleaf/no-empty-trans": "off",
+        "@overleaf/should-unescape-trans": "error",
+
         // https://astexplorer.net/
         "no-restricted-syntax": [
           "error",

+ 0 - 2
services/web/frontend/extracted-translations.json

@@ -78,7 +78,6 @@
   "are_you_getting_an_undefined_control_sequence_error": "",
   "are_you_still_at": "",
   "are_you_sure": "",
-  "ascending": "",
   "ask_proj_owner_to_upgrade_for_full_history": "",
   "ask_proj_owner_to_upgrade_for_longer_compiles": "",
   "ask_proj_owner_to_upgrade_for_references_search": "",
@@ -251,7 +250,6 @@
   "demonstrating_git_integration": "",
   "demonstrating_track_changes_feature": "",
   "department": "",
-  "descending": "",
   "description": "",
   "dictionary": "",
   "did_you_know_institution_providing_professional": "",

+ 2 - 0
services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.jsx

@@ -34,6 +34,8 @@ export default function ErrorMessage({ error }) {
               values={{
                 nameLimit: fileNameLimit,
               }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </DangerMessage>
         )

+ 2 - 0
services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.jsx

@@ -215,6 +215,8 @@ function UploadErrorMessage({ error, maxNumberOfFiles }) {
         <Trans
           i18nKey="maximum_files_uploaded_together"
           values={{ max: maxNumberOfFiles }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       )
 

+ 2 - 0
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.jsx

@@ -34,6 +34,8 @@ export default function RedirectToLogin() {
     <Trans
       i18nKey="session_expired_redirecting_to_login"
       values={{ seconds: secondsToRedirect }}
+      shouldUnescape
+      tOptions={{ interpolation: { escapeValue: true } }}
     />
   )
 }

+ 2 - 0
services/web/frontend/js/features/file-tree/components/modals/file-tree-modal-error.jsx

@@ -46,6 +46,8 @@ function FileTreeModalError() {
             i18nKey="file_already_exists_in_this_location"
             components={[<strong />]} // eslint-disable-line react/jsx-key
             values={{ fileName: error.entityName }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         )
       case InvalidFilenameError:

+ 6 - 0
services/web/frontend/js/features/file-view/components/file-view-header.tsx

@@ -123,6 +123,8 @@ function UrlProvider({ file }: UrlProviderProps) {
           formattedDate: formatTime(file.created),
           relativeDate: relativeDate(file.created),
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
       />
     </p>
   )
@@ -156,6 +158,8 @@ function ProjectFilePathProvider({ file }: ProjectFilePathProviderProps) {
           formattedDate: formatTime(file.created),
           relativeDate: relativeDate(file.created),
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
       />
     </p>
     /* esline-enable jsx-a11y/anchor-has-content, react/jsx-key */
@@ -189,6 +193,8 @@ function ProjectOutputFileProvider({ file }: ProjectOutputFileProviderProps) {
           formattedDate: formatTime(file.created),
           relativeDate: relativeDate(file.created),
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
       />
     </p>
   )

+ 2 - 0
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -68,6 +68,8 @@ export default function GroupMembers() {
                       i18nKey="you_have_added_x_of_group_size_y"
                       components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
                       values={{ addedUsersSize: users.length, groupSize }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
                     />
                   </small>
                 )}

+ 10 - 0
services/web/frontend/js/features/group-management/components/managed-users/managed-users-list-alert.tsx

@@ -69,6 +69,8 @@ function ResendManagedUserInviteSuccess({
         values={{
           email: invitedUserEmail,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,
@@ -89,6 +91,8 @@ function FailedToResendManagedInvite({
         values={{
           email: invitedUserEmail,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,
@@ -109,6 +113,8 @@ function ResendGroupInviteSuccess({
         values={{
           email: invitedUserEmail,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,
@@ -129,6 +135,8 @@ function FailedToResendGroupInvite({
         values={{
           email: invitedUserEmail,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,
@@ -149,6 +157,8 @@ function TooManyRequests({
         values={{
           email: invitedUserEmail,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,

+ 4 - 0
services/web/frontend/js/features/history/components/diff-view/toolbar/toolbar-datetime.tsx

@@ -24,6 +24,8 @@ export default function ToolbarDatetime({ selection }: ToolbarDatetimeProps) {
               'Do MMMM · h:mm a'
             ),
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       ) : (
         <Trans
@@ -36,6 +38,8 @@ export default function ToolbarDatetime({ selection }: ToolbarDatetimeProps) {
               'Do MMMM · h:mm a'
             ),
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       )}
     </div>

+ 1 - 1
services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts

@@ -2,7 +2,7 @@ import displayNameForUser from '../../../ide/history/util/displayNameForUser'
 import moment from 'moment/moment'
 import ColorManager from '../../../ide/colors/ColorManager'
 import { DocDiffChunk, Highlight } from '../services/types/doc'
-import { TFunction } from 'react-i18next'
+import { TFunction } from 'i18next'
 
 export function highlightsFromDiffResponse(
   chunks: DocDiffChunk[],

+ 4 - 0
services/web/frontend/js/features/pdf-preview/components/compile-timeout-messages.tsx

@@ -201,6 +201,8 @@ function CompileTimeoutMessages() {
                     i18nKey="compile_timeout_will_be_reduced_project_exceeds_limit_speed_up_compile"
                     components={compileTimeoutBlogLinks}
                     values={{ date: 'October 27 2023' }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />{' '}
                   <Trans i18nKey="and_you_can_upgrade_for_plenty_more_compile_time" />
                 </p>
@@ -222,6 +224,8 @@ function CompileTimeoutMessages() {
                     i18nKey="compile_timeout_will_be_reduced_project_exceeds_limit_speed_up_compile"
                     components={compileTimeoutBlogLinks}
                     values={{ date: 'October 27 2023' }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />
                 </p>
                 <p className="row-spaced">

+ 2 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.jsx

@@ -72,6 +72,8 @@ function PdfPreviewError({ error }) {
                     getMeta('ol-compilesUserContentDomain')
                   ).hostname,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
                 components={[
                   <code key="domain" />,
                   /* eslint-disable-next-line jsx-a11y/anchor-has-content */

+ 4 - 0
services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt-new.tsx

@@ -112,6 +112,7 @@ const CompileTimeout = memo(function CompileTimeout({
           )}
         </>
       }
+      // @ts-ignore
       entryAriaLabel={t('your_compile_timed_out')}
       level="error"
     />
@@ -163,6 +164,8 @@ const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
                     />,
                   ]}
                   values={{ date: 'October 27 2023' }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
                 />
               </>
             )}
@@ -229,6 +232,7 @@ const PreventTimeoutHelpMessage = memo(function PreventTimeoutHelpMessage({
           </p>
         </>
       }
+      // @ts-ignore
       entryAriaLabel={t('other_ways_to_prevent_compile_timeouts')}
       level="raw"
     />

+ 2 - 0
services/web/frontend/js/features/project-list/components/current-plan-widget/group-plan.tsx

@@ -25,6 +25,8 @@ function GroupPlan({
           i18nKey="trial_remaining_days"
           components={{ b: <strong /> }}
           values={{ days: remainingTrialDays }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       )
     ) : (

+ 2 - 0
services/web/frontend/js/features/project-list/components/current-plan-widget/individual-plan.tsx

@@ -24,6 +24,8 @@ function IndividualPlan({
           i18nKey="trial_remaining_days"
           components={{ b: <strong /> }}
           values={{ days: remainingTrialDays }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       )
     ) : (

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/ads/inr-banner.tsx

@@ -123,6 +123,8 @@ export default function INRBanner({ variant, splitTestName }: INRBannerProps) {
             i18nKey="inr_discount_offer_green_banner"
             components={[<b />, <br />]} // eslint-disable-line react/jsx-key
             values={{ flag: '🇮🇳' }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         </Notification.Body>
         <Notification.Action>

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/ads/latam-banner.tsx

@@ -89,6 +89,8 @@ export default function LATAMBanner() {
           i18nKey="latam_discount_offer"
           components={[<b />]} // eslint-disable-line react/jsx-key
           values={{ flag, currencyName, discountPercent }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </Notification.Body>
       <Notification.Action>

+ 4 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx

@@ -62,6 +62,8 @@ function ReconfirmAffiliation({
           i18nKey="please_check_your_inbox_to_confirm"
           components={[<b />]} // eslint-disable-line react/jsx-key
           values={{ institutionName: institution.name }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
         &nbsp;
         {isLoading ? (
@@ -113,6 +115,8 @@ function ReconfirmAffiliation({
         i18nKey="are_you_still_at"
         components={[<b />]} // eslint-disable-line react/jsx-key
         values={{ institutionName: institution.name }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
       />
       &nbsp;
       <Trans

+ 10 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx

@@ -69,6 +69,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
                 i18nKey="notification_project_invite_accepted_message"
                 components={{ b: <b /> }}
                 values={{ projectName: notification.messageOpts.projectName }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             ) : (
               <Trans
@@ -78,6 +80,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
                   userName: notification.messageOpts.userName,
                   projectName: notification.messageOpts.projectName,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             )}
           </Notification.Body>
@@ -136,6 +140,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
               values={{
                 institutionName: notification.messageOpts.university_name,
               }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
             <br />
             {notification.messageOpts.ssoEnabled ? (
@@ -163,6 +169,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
                   values={{
                     institutionName: notification.messageOpts.university_name,
                   }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
                 />
                 <br />
                 {t('add_email_to_claim_features')}
@@ -219,6 +227,8 @@ function CommonNotification({ notification }: CommonNotificationProps) {
                 i18nKey="dropbox_duplicate_project_names"
                 components={[<b />]} // eslint-disable-line react/jsx-key
                 values={{ projectName: notification.messageOpts.projectName }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             </p>
             <p>

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/confirm-email.tsx

@@ -116,6 +116,8 @@ function ConfirmEmailNotification({ userEmail }: { userEmail: UserEmailData }) {
                   institutionName: userEmail.affiliation?.institution.name,
                   emailAddress: userEmail.email,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
                 components={[<strong />]} // eslint-disable-line react/jsx-key
               />
             </>

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-cancel-subscription.tsx

@@ -31,6 +31,8 @@ export default function GroupInvitationCancelIndividualSubscriptionNotification(
           values={{
             inviterName,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </Notification.Body>
       <Notification.Action className="group-invitation-cancel-subscription-notification-buttons">

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/group-invitation/group-invitation-join.tsx

@@ -29,6 +29,8 @@ export default function GroupInvitationNotificationJoin({
           values={{
             inviterName,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </Notification.Body>
       <Notification.Action>

+ 12 - 0
services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx

@@ -48,6 +48,8 @@ function Institution() {
                       i18nKey="can_link_institution_email_acct_to_institution_acct"
                       components={{ b: <b /> }}
                       values={{ appName, email, institutionName }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
                     />
                   </p>
                   <div>
@@ -55,6 +57,8 @@ function Institution() {
                       i18nKey="doing_this_allow_log_in_through_institution"
                       components={{ b: <b /> }}
                       values={{ appName }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
                     />{' '}
                     <a href="/learn/how-to/Institutional_Login">
                       {t('learn_more')}
@@ -82,6 +86,8 @@ function Institution() {
                     i18nKey="account_has_been_link_to_institution_account"
                     components={{ b: <b /> }}
                     values={{ appName, email, institutionName }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />
                 </Notification.Body>
               </Notification>
@@ -97,11 +103,15 @@ function Institution() {
                     i18nKey="tried_to_log_in_with_email"
                     components={{ b: <b /> }}
                     values={{ appName, email: requestedEmail }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />{' '}
                   <Trans
                     i18nKey="in_order_to_match_institutional_metadata_associated"
                     components={{ b: <b /> }}
                     values={{ email: institutionEmail }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />
                 </Notification.Body>
               </Notification>
@@ -117,6 +127,8 @@ function Institution() {
                     i18nKey="tried_to_register_with_email"
                     components={{ b: <b /> }}
                     values={{ appName, email }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
                   />{' '}
                   {t('we_logged_you_in')}
                 </Notification.Body>

+ 2 - 0
services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx

@@ -23,6 +23,8 @@ function TranslationMessage() {
             i18nKey="click_here_to_view_sl_in_lng"
             components={[<strong />]} // eslint-disable-line react/jsx-key
             values={{ lngName: config.lngName }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
           <img
             className="ms-1"

+ 1 - 0
services/web/frontend/js/features/project-list/components/table/cells/last-updated-cell.tsx

@@ -18,6 +18,7 @@ export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
       }}
       // eslint-disable-next-line react/jsx-boolean-value
       shouldUnescape={true}
+      tOptions={{ interpolation: { escapeValue: true } }}
     />
   ) : (
     fromNowDate(project.lastUpdated)

+ 6 - 6
services/web/frontend/js/features/project-list/components/table/project-list-table.tsx

@@ -68,8 +68,8 @@ function ProjectListTable() {
             aria-sort={
               sort.by === 'title'
                 ? sort.order === 'asc'
-                  ? t('ascending')
-                  : t('descending')
+                  ? 'ascending'
+                  : 'descending'
                 : undefined
             }
           >
@@ -92,8 +92,8 @@ function ProjectListTable() {
             aria-sort={
               sort.by === 'owner'
                 ? sort.order === 'asc'
-                  ? t('ascending')
-                  : t('descending')
+                  ? 'ascending'
+                  : 'descending'
                 : undefined
             }
           >
@@ -110,8 +110,8 @@ function ProjectListTable() {
             aria-sort={
               sort.by === 'lastUpdated'
                 ? sort.order === 'asc'
-                  ? t('ascending')
-                  : t('descending')
+                  ? 'ascending'
+                  : 'descending'
                 : undefined
             }
           >

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx

@@ -33,6 +33,8 @@ function ConfirmationModal({
             i18nKey="do_you_want_to_change_your_primary_email_address_to"
             components={{ b: <b /> }}
             values={{ email }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         </p>
         <p className="mb-0">{t('log_in_with_primary_email_address')}</p>

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -117,6 +117,8 @@ function AddEmail() {
                   values={{
                     emailAddressLimit,
                   }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
                   components={[<strong />]} // eslint-disable-line react/jsx-key
                 />
               </span>

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx

@@ -36,6 +36,7 @@ function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
           values={{ institutionName: domainInfo.university.name }}
           // eslint-disable-next-line react/jsx-boolean-value
           shouldUnescape={true}
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </p>
       <p>
@@ -45,6 +46,7 @@ function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
           values={{ institutionName: domainInfo.university.name }}
           // eslint-disable-next-line react/jsx-boolean-value
           shouldUnescape={true}
+          tOptions={{ interpolation: { escapeValue: true } }}
         />{' '}
         <a
           href="/learn/how-to/Institutional_Login"

+ 4 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx

@@ -71,6 +71,8 @@ function ReconfirmationInfoPrompt({
           values={{
             institutionName: institution.name,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={
             /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
             [<strong />]
@@ -155,6 +157,8 @@ function ReconfirmationInfoPromptText({
         values={{
           institutionName,
         }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={
           /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
           [<strong />]

+ 2 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx

@@ -17,6 +17,8 @@ function ReconfirmationInfoSuccess({
       <Trans
         i18nKey="your_affiliation_is_confirmed"
         values={{ institutionName: institution.name }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[<strong />]} // eslint-disable-line react/jsx-key
       />{' '}
       {t('thank_you_exclamation')}

+ 4 - 0
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -98,6 +98,8 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
                 values={{
                   institutionName: userEmailData.affiliation?.institution.name,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             </p>
           </EmailCell>
@@ -120,6 +122,8 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
                     institutionName:
                       userEmailData.affiliation?.institution.name,
                   }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
                   components={
                     /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
                     [<strong />]

+ 6 - 0
services/web/frontend/js/features/settings/components/emails/sso-alert.tsx

@@ -64,6 +64,8 @@ export function SSOAlert() {
               i18nKey="institution_acct_successfully_linked_2"
               components={[<strong />]} // eslint-disable-line react/jsx-key
               values={{ institutionName: institutionLinked.universityName }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </p>
           {institutionLinked.hasEntitlement && (
@@ -72,6 +74,8 @@ export function SSOAlert() {
                 i18nKey="this_grants_access_to_features_2"
                 components={[<strong />]} // eslint-disable-line react/jsx-key
                 values={{ featureType: t('professional') }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             </p>
           )}
@@ -92,6 +96,8 @@ export function SSOAlert() {
               i18nKey="in_order_to_match_institutional_metadata_2"
               components={[<strong />]} // eslint-disable-line react/jsx-key
               values={{ email: institutionEmailNonCanonical }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </p>
         </Alert>

+ 2 - 0
services/web/frontend/js/features/settings/components/leave/modal-form.tsx

@@ -107,6 +107,8 @@ function LeaveModalForm({
           values={{
             userDefaultEmail,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </Checkbox>
       {error ? <LeaveModalFormError error={error} /> : null}

+ 2 - 0
services/web/frontend/js/features/settings/components/managed-account-alert.tsx

@@ -25,6 +25,8 @@ export default function ManagedAccountAlert() {
               values={{
                 admin: currentManagedUserAdminEmail,
               }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </strong>
         </div>

+ 2 - 0
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-default.jsx

@@ -21,6 +21,8 @@ export default function AddCollaboratorsUpgradeContentDefault() {
           <Trans
             i18nKey="collabs_per_proj"
             values={{ collabcount: 'Multiple' }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         </li>
         <li>

+ 2 - 0
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade-content-variant.jsx

@@ -24,6 +24,8 @@ export default function AddCollaboratorsUpgradeContentVariant() {
             <Trans
               i18nKey="collabs_per_proj"
               values={{ collabcount: 'Multiple' }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </li>
           <li>

+ 1 - 0
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx

@@ -44,6 +44,7 @@ export default function TransferOwnershipModal({ member, cancel }) {
             components={[<strong key="strong-1" />, <strong key="strong-2" />]}
             // eslint-disable-next-line react/jsx-boolean-value
             shouldUnescape={true}
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         </p>
         <p>

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx

@@ -37,6 +37,8 @@ export default function GroupSubscriptionMembership({
             groupName: subscription.teamName || '',
             adminEmail: subscription.admin_id.email,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </p>
       {subscription.teamNotice && (

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx

@@ -31,6 +31,8 @@ function InstitutionMemberships() {
               planName: 'Professional',
               institutionName: institution.name || '',
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
               <a href="/user/subscription/plans" rel="noopener" />,

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx

@@ -30,6 +30,8 @@ export default function ManagedGroupSubscriptions() {
                   groupName: subscription.teamName || '',
                   adminEmail: subscription.admin_id.email,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             ) : (
               <Trans
@@ -40,6 +42,8 @@ export default function ManagedGroupSubscriptions() {
                   groupName: subscription.teamName || '',
                   adminEmail: subscription.admin_id.email,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
               />
             )}
           </p>

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/managed-institution.tsx

@@ -48,6 +48,8 @@ export default function ManagedInstitution({
           values={{
             institutionName: institution.name || '',
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </p>
       <RowLink

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/managed-publisher.tsx

@@ -18,6 +18,8 @@ export default function ManagedPublisher({ publisher }: ManagedPublisherProps) {
           values={{
             publisherName: publisher.name || '',
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </p>
       <RowLink

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx

@@ -35,6 +35,8 @@ function PersonalSubscriptionRecurlySyncEmail() {
                   i18nKey="recurly_email_update_needed"
                   components={[<em />, <em />]} // eslint-disable-line react/jsx-key
                   values={{ recurlyEmail, userEmail }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
                 />
               </p>
               <div>

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -39,6 +39,8 @@ export function ActiveSubscription({
           values={{
             planName: subscription.plan.name,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={[
             // eslint-disable-next-line react/jsx-key
             <strong />,
@@ -95,6 +97,8 @@ export function ActiveSubscription({
             paymentAmmount: subscription.recurly.displayPrice,
             collectionDate: subscription.recurly.nextPaymentDueAt,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={[
             // eslint-disable-next-line react/jsx-key
             <strong />,

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -78,6 +78,8 @@ function NotCancelOption({
             values={{
               days: 14,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key
               <strong />,
@@ -105,6 +107,8 @@ function NotCancelOption({
             values={{
               price: planToDowngradeTo.displayPrice,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key
               <strong />,

+ 24 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx

@@ -27,6 +27,8 @@ function GroupPlanCollaboratorCount({ planCode }: { planCode: string }) {
           values={{
             collabcount: 10,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </>
     )
@@ -44,6 +46,8 @@ function EducationDiscountAppliedOrNot({ groupSize }: { groupSize: string }) {
         <Trans
           i18nKey="educational_percent_discount_applied"
           values={{ percent: educationalPercentDiscount }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
         />
       </p>
     )
@@ -54,6 +58,8 @@ function EducationDiscountAppliedOrNot({ groupSize }: { groupSize: string }) {
       <Trans
         i18nKey="educational_discount_for_groups_of_x_or_more"
         values={{ size: groupSizeForEducationalDiscount }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
       />
     </p>
   )
@@ -92,6 +98,8 @@ function GroupPrice({
           <Trans
             i18nKey="x_price_per_year"
             values={{ price: groupPlanToChangeToPrice?.totalForDisplay }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         )}
       </span>
@@ -105,6 +113,8 @@ function GroupPrice({
             values={{
               price: perUserPrice,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
           />
         </span>
         <span className="sr-only">
@@ -116,6 +126,8 @@ function GroupPrice({
               values={{
                 price: perUserPrice,
               }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           )}
         </span>
@@ -208,6 +220,8 @@ export function ChangeToGroupModal() {
               values={{
                 percent: '30',
               }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
             />
           </h3>
         </div>
@@ -296,6 +310,8 @@ export function ChangeToGroupModal() {
                         percent: educationalPercentDiscount,
                         size: groupSizeForEducationalDiscount,
                       }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
                     />
                   </strong>
                 </div>
@@ -345,6 +361,8 @@ export function ChangeToGroupModal() {
                   subtotal: groupPlanToChangeToPrice.subtotal,
                   tax: groupPlanToChangeToPrice.tax,
                 }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
                 components={[
                   /* eslint-disable-next-line react/jsx-key */
                   <strong />,
@@ -375,7 +393,12 @@ export function ChangeToGroupModal() {
           </button>
           <hr className="thin" />
           <button className="btn-inline-link" onClick={handleGetInTouchButton}>
-            <Trans i18nKey="need_more_than_x_licenses" values={{ x: 50 }} />{' '}
+            <Trans
+              i18nKey="need_more_than_x_licenses"
+              values={{ x: 50 }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />{' '}
             {t('please_get_in_touch')}
           </button>
         </div>

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx

@@ -69,6 +69,8 @@ export function ConfirmChangePlanModal() {
             values={{
               planName: plan.name,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key
               <strong />,

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx

@@ -57,6 +57,8 @@ export function KeepCurrentPlanModal() {
             values={{
               planName: personalSubscription.plan.name,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key
               <strong />,

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx

@@ -14,6 +14,8 @@ export function PendingAdditionalLicenses({
         additionalLicenses,
         totalLicenses,
       }}
+      shouldUnescape
+      tOptions={{ interpolation: { escapeValue: true } }}
       components={[
         // eslint-disable-next-line react/jsx-key
         <strong />,

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-plan-change.tsx

@@ -16,6 +16,8 @@ export function PendingPlanChange({
           values={{
             pendingPlanName: subscription.pendingPlan.name,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={[
             // eslint-disable-next-line react/jsx-key
             <strong />,
@@ -35,6 +37,8 @@ export function PendingPlanChange({
                 subscription.recurly.pendingAdditionalLicenses,
               pendingTotalLicenses: subscription.recurly.pendingTotalLicenses,
             }}
+            shouldUnescape
+            tOptions={{ interpolation: { escapeValue: true } }}
             components={[
               // eslint-disable-next-line react/jsx-key
               <strong />,

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx

@@ -17,6 +17,8 @@ function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
       values={{
         terminationDate: subscription.recurly.nextPaymentDueAt,
       }}
+      shouldUnescape
+      tOptions={{ interpolation: { escapeValue: true } }}
       components={[
         // eslint-disable-next-line react/jsx-key
         <strong />,
@@ -28,6 +30,8 @@ function SubscriptionRemainder({ subscription }: SubscriptionRemainderProps) {
       values={{
         terminationDate: subscription.recurly.nextPaymentDueAt,
       }}
+      shouldUnescape
+      tOptions={{ interpolation: { escapeValue: true } }}
       components={[
         // eslint-disable-next-line react/jsx-key
         <strong />,

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/trial-ending.tsx

@@ -10,6 +10,8 @@ export function TrialEnding({
       <Trans
         i18nKey="youre_on_free_trial_which_ends_on"
         values={{ date: trialEndsAtFormatted }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
         components={[
           // eslint-disable-next-line react/jsx-key
           <strong />,

+ 4 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/canceled.tsx

@@ -17,6 +17,8 @@ export function CanceledSubscription({
           values={{
             planName: subscription.plan.name,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={[
             // eslint-disable-next-line react/jsx-key
             <strong />,
@@ -29,6 +31,8 @@ export function CanceledSubscription({
           values={{
             terminateDate: subscription.recurly.nextPaymentDueAt,
           }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
           components={[
             // eslint-disable-next-line react/jsx-key
             <strong />,

+ 2 - 0
services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx

@@ -35,6 +35,8 @@ function SuccessfulSubscription() {
                         paymentAmmount: subscription.recurly.displayPrice,
                         collectionDate: subscription.recurly.nextPaymentDueAt,
                       }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
                       components={[<strong />, <strong />]} // eslint-disable-line react/jsx-key
                     />
                   </p>

+ 4 - 0
services/web/frontend/js/i18n.js

@@ -33,6 +33,10 @@ i18n.use(initReactI18next).init({
     // injection via another nested value
     skipOnVariables: true,
 
+    // Do not escape values, as `t` + React will already escape them
+    // (`escapeValue: true` and `shouldUnescape` must be set on each use of `Trans`)
+    escapeValue: false,
+
     defaultVariables: {
       appName: window.ExposedSettings.appName,
     },

+ 4 - 4
services/web/package.json

@@ -109,9 +109,9 @@
     "fuse.js": "^3.0.0",
     "globby": "^5.0.0",
     "helmet": "^6.0.1",
-    "i18next": "^19.6.3",
-    "i18next-fs-backend": "^1.0.7",
-    "i18next-http-middleware": "^3.0.2",
+    "i18next": "^23.5.1",
+    "i18next-fs-backend": "^2.2.0",
+    "i18next-http-middleware": "^3.4.1",
     "jose": "^4.3.8",
     "json2csv": "^4.3.3",
     "jsonwebtoken": "^9.0.0",
@@ -316,7 +316,7 @@
     "react-dom": "^17.0.2",
     "react-error-boundary": "^2.3.1",
     "react-google-recaptcha": "^3.1.0",
-    "react-i18next": "^11.18.6",
+    "react-i18next": "^13.3.0",
     "react-linkify": "^1.0.0-alpha",
     "react-refresh": "^0.14.0",
     "react-resizable-panels": "^0.0.55",

+ 4 - 4
services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx

@@ -31,7 +31,7 @@ describe('<ProjectListTable />', function () {
     const columns = screen.getAllByRole('columnheader')
     columns.forEach(col => {
       if (col.getAttribute('aria-label') === 'Last Modified') {
-        expect(col.getAttribute('aria-sort')).to.equal('Descending')
+        expect(col.getAttribute('aria-sort')).to.equal('descending')
         foundSortedColumn = true
       } else {
         expect(col.getAttribute('aria-sort')).to.be.null
@@ -46,14 +46,14 @@ describe('<ProjectListTable />', function () {
       name: /last modified/i,
     })
     const lastModifiedCol = lastModifiedBtn.closest('th')
-    expect(lastModifiedCol?.getAttribute('aria-sort')).to.equal('Descending')
+    expect(lastModifiedCol?.getAttribute('aria-sort')).to.equal('descending')
     const ownerBtn = screen.getByRole('button', { name: /owner/i })
     const ownerCol = ownerBtn.closest('th')
     expect(ownerCol?.getAttribute('aria-sort')).to.be.null
     fireEvent.click(ownerBtn)
-    expect(ownerCol?.getAttribute('aria-sort')).to.equal('Descending')
+    expect(ownerCol?.getAttribute('aria-sort')).to.equal('descending')
     fireEvent.click(ownerBtn)
-    expect(ownerCol?.getAttribute('aria-sort')).to.equal('Ascending')
+    expect(ownerCol?.getAttribute('aria-sort')).to.equal('ascending')
   })
 
   it('renders buttons for sorting all sortable columns', function () {

+ 144 - 0
services/web/test/frontend/infrastructure/i18n.spec.tsx

@@ -0,0 +1,144 @@
+import { Trans, useTranslation } from 'react-i18next'
+
+describe('i18n', function () {
+  describe('t', function () {
+    it('translates a plain string', function () {
+      const Test = () => {
+        const { t } = useTranslation()
+        return <div>{t('accept')}</div>
+      }
+      cy.mount(<Test />)
+      cy.findByText('Accept')
+    })
+
+    it('uses defaultValues', function () {
+      const Test = () => {
+        const { t } = useTranslation()
+        return <div>{t('welcome_to_sl')}</div>
+      }
+      cy.mount(<Test />)
+      cy.findByText('Welcome to Overleaf!')
+    })
+
+    it('uses values', function () {
+      const Test = () => {
+        const { t } = useTranslation()
+        return <div>{t('sort_by_x', { x: 'name' })}</div>
+      }
+      cy.mount(<Test />)
+      cy.findByText('Sort by name')
+    })
+  })
+
+  describe('Trans', function () {
+    it('translates a plain string', function () {
+      const Test = () => {
+        return (
+          <div>
+            <Trans i18nKey="accept" />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByText('Accept')
+    })
+
+    it('uses defaultValues', function () {
+      const Test = () => {
+        return (
+          <div>
+            <Trans i18nKey="welcome_to_sl" />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByText('Welcome to Overleaf!')
+    })
+
+    it('uses values', function () {
+      const Test = () => {
+        return (
+          <div>
+            <Trans
+              i18nKey="sort_by_x"
+              values={{ x: 'name' }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByText('Sort by name')
+    })
+
+    it('uses an object of components', function () {
+      const Test = () => {
+        return (
+          <div data-testid="container">
+            <Trans
+              i18nKey="in_order_to_match_institutional_metadata_associated"
+              components={{ b: <b /> }}
+              values={{ email: 'test@example.com' }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByTestId('container')
+        .should(
+          'have.text',
+          'In order to match your institutional metadata, your account is associated with the email test@example.com.'
+        )
+        .find('b')
+        .should('have.length', 1)
+        .should('have.text', 'test@example.com')
+    })
+
+    it('uses an array of components', function () {
+      const Test = () => {
+        return (
+          <div data-testid="container">
+            <Trans
+              i18nKey="are_you_still_at"
+              components={[<b />]} // eslint-disable-line react/jsx-key
+              values={{ institutionName: 'Test' }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByTestId('container')
+        .should('have.text', 'Are you still at Test?')
+        .find('b')
+        .should('have.length', 1)
+        .should('have.text', 'Test')
+    })
+
+    it('escapes special characters', function () {
+      const Test = () => {
+        return (
+          <div data-testid="container">
+            <Trans
+              i18nKey="are_you_still_at"
+              components={[<b />]} // eslint-disable-line react/jsx-key
+              values={{ institutionName: "T&e's<code>t</code>ing" }}
+              shouldUnescape
+              tOptions={{ interpolation: { escapeValue: true } }}
+            />
+          </div>
+        )
+      }
+      cy.mount(<Test />)
+      cy.findByTestId('container')
+        .should('have.text', "Are you still at T&e's<code>t</code>ing?")
+        .find('b')
+        .should('have.length', 1)
+        .should('have.text', "T&e's<code>t</code>ing")
+    })
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов