Make i18n more robust to failure
Now we actually format the fallback key if we can't find the language's translation of a string key. In the rare case that the fallback language actually doesn't have a key, we just return the key name and a little notice that something's gone wrong. Local storage stuff is now handled by our i18n impl (upon setting a language, if it's not the fallback language, we'll write it to localstorage automatically so we remember the user's decision), just to cleanup the initalization in main.ts (and also because it's a bit simpler that way). Also moved initalization to a DOMContentLoaded event, since that can be made async.
This commit is contained in:
parent
6327036283
commit
3a7e590797
|
|
@ -1,6 +1,6 @@
|
||||||
import { StringLike } from './StringLike';
|
import { StringLike } from './StringLike';
|
||||||
|
|
||||||
// Nice little string key helper
|
/// All string keys.
|
||||||
export enum I18nStringKey {
|
export enum I18nStringKey {
|
||||||
kSiteName = 'kSiteName',
|
kSiteName = 'kSiteName',
|
||||||
kHomeButton = 'kHomeButton',
|
kHomeButton = 'kHomeButton',
|
||||||
|
|
@ -49,7 +49,7 @@ const fallbackLanguage: Language = {
|
||||||
author: 'Computernewb',
|
author: 'Computernewb',
|
||||||
|
|
||||||
stringKeys: {
|
stringKeys: {
|
||||||
kTitle: 'CollabVM',
|
kSiteName: 'CollabVM',
|
||||||
kHomeButton: 'Home',
|
kHomeButton: 'Home',
|
||||||
kFAQButton: 'FAQ',
|
kFAQButton: 'FAQ',
|
||||||
kRulesButton: 'Rules',
|
kRulesButton: 'Rules',
|
||||||
|
|
@ -72,8 +72,8 @@ const fallbackLanguage: Language = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StringMap {
|
interface StringKeyMap {
|
||||||
[k: string]: string;
|
[k: string]: I18nStringKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// our fancy internationalization helper.
|
/// our fancy internationalization helper.
|
||||||
|
|
@ -84,27 +84,47 @@ export class I18n {
|
||||||
// the ID of the language
|
// the ID of the language
|
||||||
private langId: string = fallbackId;
|
private langId: string = fallbackId;
|
||||||
|
|
||||||
async LoadLanguageFile(id: string) {
|
private async LoadLanguageFile(id: string) {
|
||||||
let languageData = await I18n.LoadLanguageFileImpl(id);
|
let languageData = await I18n.LoadLanguageFileImpl(id);
|
||||||
this.SetLanguage(languageData, id);
|
this.SetLanguage(languageData, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async initWithLanguage(id: string) {
|
async LoadAndSetLanguage(id: string) {
|
||||||
try {
|
try {
|
||||||
await this.LoadLanguageFile(id);
|
await this.LoadLanguageFile(id);
|
||||||
console.log("i18n initalized for", id, "sucessfully!");
|
console.log('i18n initalized for', id, 'sucessfully!');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`There was an error loading the language file for \"${id}\". Please tell a site admin this happened, and give them the following information: \"${(e as Error).message}\"`);
|
alert(
|
||||||
// force set the language to fallback
|
`There was an error loading the language file for the language \"${id}\". Please tell a site admin this happened, and give them the following information: \"${(e as Error).message}\"`
|
||||||
|
);
|
||||||
|
// force set the language to fallback and replace all strings.
|
||||||
|
// (this is done because we initialize with fallback, so SetLanguage will
|
||||||
|
// refuse to replace static strings. Hacky but it should work)
|
||||||
this.SetLanguage(fallbackLanguage, fallbackId);
|
this.SetLanguage(fallbackLanguage, fallbackId);
|
||||||
|
this.ReplaceStaticStrings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Init() {
|
||||||
|
let lang = window.localStorage.getItem('i18n-lang');
|
||||||
|
|
||||||
|
// Set a default language if not specified
|
||||||
|
if (lang == null) {
|
||||||
|
lang = 'en-us';
|
||||||
|
window.localStorage.setItem('i18n-lang', lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.LoadAndSetLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
private static async LoadLanguageFileImpl(id: string): Promise<Language> {
|
private static async LoadLanguageFileImpl(id: string): Promise<Language> {
|
||||||
let path = `./lang/${id}.json`;
|
let path = `./lang/${id}.json`;
|
||||||
let res = await fetch(path);
|
let res = await fetch(path);
|
||||||
|
|
||||||
if (!res.ok) throw new Error(res.statusText);
|
if (!res.ok) {
|
||||||
|
if (res.statusText != '') throw new Error(`Failed to load lang/${id}.json: ${res.statusText}`);
|
||||||
|
else throw new Error(`Failed to load lang/${id}.json: HTTP status code ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (await res.json()) as Language;
|
return (await res.json()) as Language;
|
||||||
}
|
}
|
||||||
|
|
@ -116,11 +136,16 @@ export class I18n {
|
||||||
|
|
||||||
// Only replace static strings
|
// Only replace static strings
|
||||||
if (this.langId != lastId) this.ReplaceStaticStrings();
|
if (this.langId != lastId) this.ReplaceStaticStrings();
|
||||||
|
|
||||||
|
// Set the language ID localstorage entry
|
||||||
|
if (this.langId !== fallbackId) {
|
||||||
|
window.localStorage.setItem('i18n-lang', this.langId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replaces static strings that we don't recompute
|
// Replaces static strings that we don't recompute
|
||||||
private ReplaceStaticStrings() {
|
private ReplaceStaticStrings() {
|
||||||
const kDomIdtoStringMap: StringMap = {
|
const kDomIdtoStringMap: StringKeyMap = {
|
||||||
siteNameText: I18nStringKey.kSiteName,
|
siteNameText: I18nStringKey.kSiteName,
|
||||||
homeBtnText: I18nStringKey.kHomeButton,
|
homeBtnText: I18nStringKey.kHomeButton,
|
||||||
faqBtnText: I18nStringKey.kFAQButton,
|
faqBtnText: I18nStringKey.kFAQButton,
|
||||||
|
|
@ -154,7 +179,7 @@ export class I18n {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gets a string, which also allows replacing by index with the given replacements.
|
// Gets a string, which also allows replacing by index with the given replacements.
|
||||||
GetString(key: string, ...replacements: StringLike[]): string {
|
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
|
||||||
let replacementStringArray: Array<string> = [...replacements].map((el) => {
|
let replacementStringArray: Array<string> = [...replacements].map((el) => {
|
||||||
// This catches cases where the thing already is a string
|
// This catches cases where the thing already is a string
|
||||||
if (typeof el == 'string') return el as string;
|
if (typeof el == 'string') return el as string;
|
||||||
|
|
@ -163,38 +188,65 @@ export class I18n {
|
||||||
|
|
||||||
let val = this.lang.stringKeys[key];
|
let val = this.lang.stringKeys[key];
|
||||||
|
|
||||||
if (val == null) {
|
// Helper to throw a more descriptive error (including the looked-up string in question)
|
||||||
|
let throwError = (desc: string) => {
|
||||||
|
throw new Error(`Invalid replacement "${val}": ${desc}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up the fallback language by default if the language doesn't
|
||||||
|
// have that string key yet; if the fallback doesn't have it either,
|
||||||
|
// then just return the string key and a bit of a notice things have gone wrong
|
||||||
|
if (val == undefined) {
|
||||||
let fallback = fallbackLanguage.stringKeys[key];
|
let fallback = fallbackLanguage.stringKeys[key];
|
||||||
if (fallback == null) return 'UH OH WORM';
|
if (fallback !== undefined) val = fallback;
|
||||||
else return fallback;
|
else return `${key} (ERROR)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle replacement ("{0} {1} {2} {3} {4} {5}" syntax) in string keys
|
||||||
|
// which allows us to just specify arguments we want to format into the final string,
|
||||||
|
// instead of hacky replacements hardcoded at the source. It's more flexible that way.
|
||||||
for (let i = 0; i < val.length; ++i) {
|
for (let i = 0; i < val.length; ++i) {
|
||||||
if (val[i] == '{') {
|
if (val[i] == '{') {
|
||||||
let replacementStart = i;
|
let replacementStart = i;
|
||||||
let foundReplacementEnd = false;
|
let foundReplacementEnd = false;
|
||||||
|
|
||||||
|
// Make sure the replacement is not cut off (the last character of the string)
|
||||||
if (i + 1 > val.length) {
|
if (i + 1 > val.length) {
|
||||||
throw new Error('Cutoff/invalid replacement');
|
throwError('Cutoff/invalid replacement');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try and find the replacement end
|
// Try and find the replacement end ('}').
|
||||||
|
// Whitespace and a '{' are considered errors.
|
||||||
for (let j = i + 1; j < val.length; ++j) {
|
for (let j = i + 1; j < val.length; ++j) {
|
||||||
if (val[j] == '}') {
|
switch (val[j]) {
|
||||||
foundReplacementEnd = true;
|
case '}':
|
||||||
i = j;
|
foundReplacementEnd = true;
|
||||||
break;
|
i = j;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '{':
|
||||||
|
throwError('Cannot start a replacement in an existing replacement');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ' ':
|
||||||
|
throwError('Whitespace inside replacement');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (foundReplacementEnd) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!foundReplacementEnd) throw new Error('Invalid replacement, has no "}" to terminate it');
|
if (!foundReplacementEnd) throwError('No terminating "}" character found');
|
||||||
|
|
||||||
// Get the beginning and trailer
|
// Get the beginning and trailer
|
||||||
let beginning = val.substring(0, replacementStart);
|
let beginning = val.substring(0, replacementStart);
|
||||||
let trailer = val.substring(replacementStart + 3);
|
let trailer = val.substring(replacementStart + 3);
|
||||||
|
|
||||||
let replacementIndex = parseInt(val.substring(replacementStart + 1, i));
|
let replacementIndex = parseInt(val.substring(replacementStart + 1, i));
|
||||||
if (Number.isNaN(replacementIndex) || replacementIndex > replacementStringArray.length) throw new Error('Invalid replacement');
|
if (Number.isNaN(replacementIndex) || replacementIndex > replacementStringArray.length) throwError('Replacement index out of bounds');
|
||||||
|
|
||||||
// This is seriously the only decent way to do this in javascript
|
// This is seriously the only decent way to do this in javascript
|
||||||
// thanks brendan eich (replace this thanking with more choice words in your head)
|
// thanks brendan eich (replace this thanking with more choice words in your head)
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,7 @@ async function openVM(vm: VM): Promise<void> {
|
||||||
unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)));
|
unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)));
|
||||||
unsubscribeCallbacks.push(
|
unsubscribeCallbacks.push(
|
||||||
VM!.on('renamestatus', (status) => {
|
VM!.on('renamestatus', (status) => {
|
||||||
// TODO: i18n these
|
// TODO: i18n these
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'taken':
|
case 'taken':
|
||||||
alert('That username is already taken');
|
alert('That username is already taken');
|
||||||
|
|
@ -837,30 +837,25 @@ w.cvmEvents = {
|
||||||
};
|
};
|
||||||
w.VMName = null;
|
w.VMName = null;
|
||||||
|
|
||||||
// Load all VMs
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
loadList();
|
// Initalize the i18n system
|
||||||
|
await TheI18n.Init();
|
||||||
|
|
||||||
// Set a default internationalization language if not specified
|
// Load all VMs
|
||||||
let lang = window.localStorage.getItem('i18n-lang');
|
await loadList();
|
||||||
if (lang == null) {
|
|
||||||
lang = 'en-us';
|
|
||||||
window.localStorage.setItem('i18n-lang', lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initalize the internationalization system
|
// Welcome modal
|
||||||
TheI18n.initWithLanguage(lang);
|
let noWelcomeModal = window.localStorage.getItem('no-welcome-modal');
|
||||||
|
if (noWelcomeModal !== '1') {
|
||||||
// Welcome modal
|
let welcomeModalDismissBtn = document.getElementById('welcomeModalDismiss') as HTMLButtonElement;
|
||||||
let noWelcomeModal = window.localStorage.getItem('no-welcome-modal');
|
let welcomeModal = new bootstrap.Modal(document.getElementById('welcomeModal') as HTMLDivElement);
|
||||||
if (noWelcomeModal !== '1') {
|
welcomeModalDismissBtn.addEventListener('click', () => {
|
||||||
let welcomeModalDismissBtn = document.getElementById('welcomeModalDismiss') as HTMLButtonElement;
|
window.localStorage.setItem('no-welcome-modal', '1');
|
||||||
let welcomeModal = new bootstrap.Modal(document.getElementById('welcomeModal') as HTMLDivElement);
|
});
|
||||||
welcomeModalDismissBtn.addEventListener('click', () => {
|
welcomeModalDismissBtn.disabled = true;
|
||||||
window.localStorage.setItem('no-welcome-modal', '1');
|
welcomeModal.show();
|
||||||
});
|
setTimeout(() => {
|
||||||
welcomeModalDismissBtn.disabled = true;
|
welcomeModalDismissBtn.disabled = false;
|
||||||
welcomeModal.show();
|
}, 5000);
|
||||||
setTimeout(() => {
|
}
|
||||||
welcomeModalDismissBtn.disabled = false;
|
});
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user