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:
parent
ea7ac453bc
commit
7f13a1b40a
7 changed files with 236 additions and 132 deletions
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue