From 3a7e590797807d64a4c9a288c22c09fc152c545d Mon Sep 17 00:00:00 2001 From: modeco80 Date: Wed, 13 Mar 2024 21:55:01 -0400 Subject: [PATCH] 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. --- src/ts/i18n.ts | 100 +++++++++++++++++++++++++++++++++++++------------ src/ts/main.ts | 47 +++++++++++------------ 2 files changed, 97 insertions(+), 50 deletions(-) diff --git a/src/ts/i18n.ts b/src/ts/i18n.ts index 3ab74ab..7ada5e8 100644 --- a/src/ts/i18n.ts +++ b/src/ts/i18n.ts @@ -1,6 +1,6 @@ import { StringLike } from './StringLike'; -// Nice little string key helper +/// All string keys. export enum I18nStringKey { kSiteName = 'kSiteName', kHomeButton = 'kHomeButton', @@ -49,7 +49,7 @@ const fallbackLanguage: Language = { author: 'Computernewb', stringKeys: { - kTitle: 'CollabVM', + kSiteName: 'CollabVM', kHomeButton: 'Home', kFAQButton: 'FAQ', kRulesButton: 'Rules', @@ -72,8 +72,8 @@ const fallbackLanguage: Language = { } }; -interface StringMap { - [k: string]: string; +interface StringKeyMap { + [k: string]: I18nStringKey; } /// our fancy internationalization helper. @@ -84,27 +84,47 @@ export class I18n { // the ID of the language private langId: string = fallbackId; - async LoadLanguageFile(id: string) { + private async LoadLanguageFile(id: string) { let languageData = await I18n.LoadLanguageFileImpl(id); this.SetLanguage(languageData, id); } - async initWithLanguage(id: string) { + async LoadAndSetLanguage(id: string) { try { await this.LoadLanguageFile(id); - console.log("i18n initalized for", id, "sucessfully!"); + console.log('i18n initalized for', id, 'sucessfully!'); } 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}\"`); - // force set the language to fallback + alert( + `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.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 { let path = `./lang/${id}.json`; 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; } @@ -116,11 +136,16 @@ export class I18n { // Only replace static strings 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 private ReplaceStaticStrings() { - const kDomIdtoStringMap: StringMap = { + const kDomIdtoStringMap: StringKeyMap = { siteNameText: I18nStringKey.kSiteName, homeBtnText: I18nStringKey.kHomeButton, faqBtnText: I18nStringKey.kFAQButton, @@ -154,7 +179,7 @@ export class I18n { } // 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 = [...replacements].map((el) => { // This catches cases where the thing already is a string if (typeof el == 'string') return el as string; @@ -163,38 +188,65 @@ export class I18n { 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]; - if (fallback == null) return 'UH OH WORM'; - else return fallback; + if (fallback !== undefined) val = 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) { if (val[i] == '{') { let replacementStart = i; let foundReplacementEnd = false; + // Make sure the replacement is not cut off (the last character of the string) 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) { - if (val[j] == '}') { - foundReplacementEnd = true; - i = j; - break; + switch (val[j]) { + case '}': + foundReplacementEnd = true; + 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 let beginning = val.substring(0, replacementStart); let trailer = val.substring(replacementStart + 3); 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 // thanks brendan eich (replace this thanking with more choice words in your head) @@ -206,4 +258,4 @@ export class I18n { } } -export let TheI18n = new I18n(); \ No newline at end of file +export let TheI18n = new I18n(); diff --git a/src/ts/main.ts b/src/ts/main.ts index 05e35ab..798c959 100644 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -324,7 +324,7 @@ async function openVM(vm: VM): Promise { unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename))); unsubscribeCallbacks.push( VM!.on('renamestatus', (status) => { - // TODO: i18n these + // TODO: i18n these switch (status) { case 'taken': alert('That username is already taken'); @@ -837,30 +837,25 @@ w.cvmEvents = { }; w.VMName = null; -// Load all VMs -loadList(); +document.addEventListener('DOMContentLoaded', async () => { + // Initalize the i18n system + await TheI18n.Init(); -// Set a default internationalization language if not specified -let lang = window.localStorage.getItem('i18n-lang'); -if (lang == null) { - lang = 'en-us'; - window.localStorage.setItem('i18n-lang', lang); -} + // Load all VMs + await loadList(); -// Initalize the internationalization system -TheI18n.initWithLanguage(lang); - -// Welcome modal -let noWelcomeModal = window.localStorage.getItem('no-welcome-modal'); -if (noWelcomeModal !== '1') { - let welcomeModalDismissBtn = document.getElementById('welcomeModalDismiss') as HTMLButtonElement; - let welcomeModal = new bootstrap.Modal(document.getElementById('welcomeModal') as HTMLDivElement); - welcomeModalDismissBtn.addEventListener('click', () => { - window.localStorage.setItem('no-welcome-modal', '1'); - }); - welcomeModalDismissBtn.disabled = true; - welcomeModal.show(); - setTimeout(() => { - welcomeModalDismissBtn.disabled = false; - }, 5000); -} + // Welcome modal + let noWelcomeModal = window.localStorage.getItem('no-welcome-modal'); + if (noWelcomeModal !== '1') { + let welcomeModalDismissBtn = document.getElementById('welcomeModalDismiss') as HTMLButtonElement; + let welcomeModal = new bootstrap.Modal(document.getElementById('welcomeModal') as HTMLDivElement); + welcomeModalDismissBtn.addEventListener('click', () => { + window.localStorage.setItem('no-welcome-modal', '1'); + }); + welcomeModalDismissBtn.disabled = true; + welcomeModal.show(); + setTimeout(() => { + welcomeModalDismissBtn.disabled = false; + }, 5000); + } +});