Attribute fallback i18n strings with lang attribute (#7323)

* add lang attribute to fallback translations

Signed-off-by: Kerry Archibald <kerrya@element.io>

* readability improvement

Signed-off-by: Kerry Archibald <kerrya@element.io>

* split _t and _tDom

Signed-off-by: Kerry <kerry@Kerrys-MBP.fritz.box>

* use tDom in HomePage

Signed-off-by: Kerry Archibald <kerrya@element.io>

* lint

Signed-off-by: Kerry Archibald <kerrya@element.io>

* bump matrix-web-i18n

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-01-05 11:37:28 +01:00 committed by GitHub
parent ea7ac453bc
commit 7f13a1b40a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 236 additions and 132 deletions

View file

@ -38,8 +38,9 @@ const ANNOTATE_STRINGS = false;
// We use english strings as keys, some of which contain full stops
counterpart.setSeparator('|');
// Fall back to English
counterpart.setFallbackLocale('en');
// see `translateWithFallback` for an explanation of fallback handling
const FALLBACK_LOCALE = 'en';
interface ITranslatableError extends Error {
translatedMessage: string;
@ -72,9 +73,32 @@ export function _td(s: string): string { // eslint-disable-line @typescript-esli
return s;
}
/**
* to improve screen reader experience translations that are not in the main page language
* eg a translation that fell back to english from another language
* should be wrapped with an appropriate `lang='en'` attribute
* counterpart's `translate` doesn't expose a way to determine if the resulting translation
* is in the target locale or a fallback locale
* for this reason, we do not set a fallback via `counterpart.setFallbackLocale`
* and fallback 'manually' so we can mark fallback strings appropriately
* */
const translateWithFallback = (text: string, options?: object): { translated?: string, isFallback?: boolean } => {
const translated = counterpart.translate(text, options);
if (/^missing translation:/.test(translated)) {
const fallbackTranslated = counterpart.translate(text, { ...options, fallbackLocale: FALLBACK_LOCALE });
return { translated: fallbackTranslated, isFallback: true };
}
return { translated };
};
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text: string, options?: object) {
function safeCounterpartTranslate(text: string, variables?: object) {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
const options = { ...variables, interpolate: false };
// Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
@ -82,10 +106,7 @@ function safeCounterpartTranslate(text: string, options?: object) {
// valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
// if there are no existing null guards. To avoid this making the app completely inoperable,
// we'll check all the values for undefined/null and stringify them here.
let count;
if (options && typeof options === 'object') {
count = options['count'];
Object.keys(options).forEach((k) => {
if (options[k] === undefined) {
logger.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
@ -97,13 +118,7 @@ function safeCounterpartTranslate(text: string, options?: object) {
}
});
}
let translated = counterpart.translate(text, options);
if (translated === undefined && count !== undefined) {
// counterpart does not do fallback if no pluralisation exists
// in the preferred language, so do it here
translated = counterpart.translate(text, Object.assign({}, options, { locale: 'en' }));
}
return translated;
return translateWithFallback(text, options);
}
type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
@ -117,6 +132,20 @@ export type Tags = Record<string, SubstitutionValue>;
export type TranslatedString = string | React.ReactNode;
// For development/testing purposes it is useful to also output the original string
// Don't do that for release versions
const annotateStrings = (result: TranslatedString, translationKey: string): TranslatedString => {
if (!ANNOTATE_STRINGS) {
return result;
}
if (typeof result === 'string') {
return `@@${translationKey}##${result}@@`;
} else {
return <span className='translated-string' data-orig-string={translationKey}>{ result }</span>;
}
};
/*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@ -134,31 +163,39 @@ export type TranslatedString = string | React.ReactNode;
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
// eslint-nexline @typescript-eslint/naming-convention
export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): TranslatedString {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too
const args = Object.assign({ interpolate: false }, variables);
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const translated = safeCounterpartTranslate(text, args);
const { translated } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
// For development/testing purposes it is useful to also output the original string
// Don't do that for release versions
if (ANNOTATE_STRINGS) {
if (typeof substituted === 'string') {
return `@@${text}##${substituted}@@`;
} else {
return <span className='translated-string' data-orig-string={text}>{ substituted }</span>;
}
} else {
return substituted;
}
return annotateStrings(substituted, text);
}
/*
* Wraps normal _t function and adds atttribution for translations that used a fallback locale
* Wraps translations that fell back from active locale to fallback locale with a `<span lang=<fallback locale>>`
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* @return a React <span> component if any non-strings were used in substitutions
* or translation used a fallback locale, otherwise a string
*/
// eslint-next-line @typescript-eslint/naming-convention
export function _tDom(text: string, variables?: IVariables): TranslatedString;
export function _tDom(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _tDom(text: string, variables?: IVariables, tags?: Tags): TranslatedString {
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
const { translated, isFallback } = safeCounterpartTranslate(text, variables);
const substituted = substitute(translated, variables, tags);
// wrap en fallback translation with lang attribute for screen readers
const result = isFallback ? <span lang='en'>{ substituted }</span> : substituted;
return annotateStrings(result, text);
}
/**