mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 05:41:13 +00:00
Lazy load translations and date-fns, server side support for "Browser Default" language (#2380)
* Lazy load i18n translations. * Lazy load date-fns * Fix inconsistent DOMContentLoaded event. Only when no translations and date-fns have to be dynamically loaded (e.g. for en-US) the NavBar `componentDidMount` is early enough to listen for "DOMContentLoaded". Removes one redundant `requestNotificationPermission()` call. * Rename interface language code "pt_BR" to "pt-BR". Browsers ask for "pt-BR", but the "interface_language" saved in the settings dialog asks for "pt_BR". This change will make the settings dialog ask for "pt-BR" instead of "pt_BR". For users that already (or still) have "pt_BR" configured, "pt-BR" will be used, but the settings dialog will present it as unspecified. * Use Accept-Language request header * Prefetch translation and date-fns --------- Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com>
This commit is contained in:
parent
c80136ed65
commit
e832cd2729
13 changed files with 495 additions and 117 deletions
|
@ -1,16 +1,23 @@
|
|||
import { initializeSite, setupDateFns } from "@utils/app";
|
||||
import { initializeSite } from "@utils/app";
|
||||
import { hydrate } from "inferno-hydrate";
|
||||
import { BrowserRouter } from "inferno-router";
|
||||
import { App } from "../shared/components/app/app";
|
||||
import { loadUserLanguage } from "../shared/services/I18NextService";
|
||||
import { verifyDynamicImports } from "../shared/dynamic-imports";
|
||||
|
||||
import "bootstrap/js/dist/collapse";
|
||||
import "bootstrap/js/dist/dropdown";
|
||||
import "bootstrap/js/dist/modal";
|
||||
|
||||
async function startClient() {
|
||||
// Allows to test imports from the browser console.
|
||||
window.checkLazyScripts = () => {
|
||||
verifyDynamicImports(true).then(x => console.log(x));
|
||||
};
|
||||
|
||||
initializeSite(window.isoData.site_res);
|
||||
|
||||
await setupDateFns();
|
||||
await loadUserLanguage();
|
||||
|
||||
const wrapper = (
|
||||
<BrowserRouter>
|
||||
|
@ -22,6 +29,8 @@ async function startClient() {
|
|||
|
||||
if (root) {
|
||||
hydrate(wrapper, root);
|
||||
|
||||
root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,26 @@ import { createSsrHtml } from "../utils/create-ssr-html";
|
|||
import { getErrorPageData } from "../utils/get-error-page-data";
|
||||
import { setForwardedHeaders } from "../utils/set-forwarded-headers";
|
||||
import { getJwtCookie } from "../utils/has-jwt-cookie";
|
||||
import {
|
||||
I18NextService,
|
||||
LanguageService,
|
||||
UserService,
|
||||
} from "../../shared/services/";
|
||||
|
||||
export default async (req: Request, res: Response) => {
|
||||
try {
|
||||
const languages: string[] =
|
||||
req.headers["accept-language"]
|
||||
?.split(",")
|
||||
.map(x => {
|
||||
const [head, tail] = x.split(/;\s*q?\s*=?/); // at ";", remove "q="
|
||||
const q = Number(tail ?? 1); // no q means q=1
|
||||
return { lang: head.trim(), q: Number.isNaN(q) ? 0 : q };
|
||||
})
|
||||
.filter(x => x.lang)
|
||||
.sort((a, b) => b.q - a.q)
|
||||
.map(x => (x.lang === "*" ? "en" : x.lang)) ?? [];
|
||||
|
||||
const activeRoute = routes.find(route => matchPath(req.path, route));
|
||||
|
||||
const headers = setForwardedHeaders(req.headers);
|
||||
|
@ -60,6 +77,7 @@ export default async (req: Request, res: Response) => {
|
|||
if (try_site.state === "success") {
|
||||
site = try_site.data;
|
||||
initializeSite(site);
|
||||
LanguageService.updateLanguages(languages);
|
||||
|
||||
if (path !== "/setup" && !site.site_view.local_site.site_setup) {
|
||||
return res.redirect("/setup");
|
||||
|
@ -73,6 +91,16 @@ export default async (req: Request, res: Response) => {
|
|||
headers,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
setTimeout(() => {
|
||||
// Intentionally (likely) break things if fetchInitialData tries to
|
||||
// use global state after the first await of an unresolved promise.
|
||||
// This simulates another request entering or leaving this
|
||||
// "success" block.
|
||||
UserService.Instance.myUserInfo = undefined;
|
||||
I18NextService.i18n.changeLanguage("cimode");
|
||||
});
|
||||
}
|
||||
routeData = await activeRoute.fetchInitialData(initialFetchReq);
|
||||
}
|
||||
|
||||
|
@ -114,9 +142,20 @@ export default async (req: Request, res: Response) => {
|
|||
</StaticRouter>
|
||||
);
|
||||
|
||||
// Another request could have initialized a new site.
|
||||
initializeSite(site);
|
||||
LanguageService.updateLanguages(languages);
|
||||
|
||||
const root = renderToString(wrapper);
|
||||
|
||||
res.send(await createSsrHtml(root, isoData, res.locals.cspNonce));
|
||||
res.send(
|
||||
await createSsrHtml(
|
||||
root,
|
||||
isoData,
|
||||
res.locals.cspNonce,
|
||||
LanguageService.userLanguages,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// If an error is caught here, the error page couldn't even be rendered
|
||||
console.error(err);
|
||||
|
|
|
@ -13,6 +13,7 @@ import ThemeHandler from "./handlers/theme-handler";
|
|||
import ThemesListHandler from "./handlers/themes-list-handler";
|
||||
import { setCacheControl, setDefaultCsp } from "./middleware";
|
||||
import CodeThemeHandler from "./handlers/code-theme-handler";
|
||||
import { verifyDynamicImports } from "../shared/dynamic-imports";
|
||||
|
||||
const server = express();
|
||||
|
||||
|
@ -54,6 +55,8 @@ server.get("/css/themelist", ThemesListHandler);
|
|||
server.get("/*", CatchAllHandler);
|
||||
|
||||
const listener = server.listen(Number(port), hostname, () => {
|
||||
verifyDynamicImports(true);
|
||||
|
||||
setupDateFns();
|
||||
console.log(
|
||||
`Lemmy-ui v${VERSION} started listening on http://${hostname}:${port}`,
|
||||
|
|
|
@ -7,6 +7,8 @@ import { favIconPngUrl, favIconUrl } from "../../shared/config";
|
|||
import { IsoDataOptionalSite } from "../../shared/interfaces";
|
||||
import { buildThemeList } from "./build-themes-list";
|
||||
import { fetchIconPng } from "./fetch-icon-png";
|
||||
import { findTranslationChunkNames } from "../../shared/services/I18NextService";
|
||||
import { findDateFnsChunkNames } from "../../shared/utils/app/setup-date-fns";
|
||||
|
||||
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
|
||||
|
||||
|
@ -16,6 +18,7 @@ export async function createSsrHtml(
|
|||
root: string,
|
||||
isoData: IsoDataOptionalSite,
|
||||
cspNonce: string,
|
||||
userLanguages: readonly string[],
|
||||
) {
|
||||
const site = isoData.site_res;
|
||||
|
||||
|
@ -63,10 +66,20 @@ export async function createSsrHtml(
|
|||
|
||||
const helmet = Helmet.renderStatic();
|
||||
|
||||
const lazyScripts = [
|
||||
...findTranslationChunkNames(userLanguages),
|
||||
...findDateFnsChunkNames(userLanguages),
|
||||
]
|
||||
.filter(x => x !== undefined)
|
||||
.map(x => `${getStaticDir()}/js/${x}.client.js`)
|
||||
.map(x => `<link rel="preload" as="script" href="${x}" />`)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html ${helmet.htmlAttributes.toString()}>
|
||||
<head>
|
||||
${lazyScripts}
|
||||
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
|
||||
|
||||
<!-- A remote debugging utility for mobile -->
|
||||
|
|
|
@ -78,7 +78,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
|
|||
UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe(
|
||||
unreadApplicationCount => this.setState({ unreadApplicationCount }),
|
||||
);
|
||||
this.requestNotificationPermission();
|
||||
|
||||
document.addEventListener("mouseup", this.handleOutsideMenuClick);
|
||||
}
|
||||
|
@ -468,7 +467,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
|
|||
|
||||
requestNotificationPermission() {
|
||||
if (UserService.Instance.myUserInfo) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.addEventListener("lemmy-hydrated", function () {
|
||||
if (!Notification) {
|
||||
toast(I18NextService.i18n.t("notifications_error"), "danger");
|
||||
return;
|
||||
|
|
|
@ -45,7 +45,11 @@ import {
|
|||
RequestState,
|
||||
wrapClient,
|
||||
} from "../../services/HttpService";
|
||||
import { I18NextService, languages } from "../../services/I18NextService";
|
||||
import {
|
||||
I18NextService,
|
||||
languages,
|
||||
loadUserLanguage,
|
||||
} from "../../services/I18NextService";
|
||||
import { setupTippy } from "../../tippy";
|
||||
import { toast } from "../../toast";
|
||||
import { HtmlTags } from "../common/html-tags";
|
||||
|
@ -335,6 +339,11 @@ export class Settings extends Component<any, SettingsState> {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
// In case `interface_language` change wasn't saved.
|
||||
loadUserLanguage();
|
||||
}
|
||||
|
||||
static async fetchInitialData({
|
||||
headers,
|
||||
}: InitialFetchRequest): Promise<SettingsData> {
|
||||
|
@ -791,7 +800,7 @@ export class Settings extends Component<any, SettingsState> {
|
|||
onChange={linkEvent(this, this.handleInterfaceLangChange)}
|
||||
className="form-select d-inline-block w-auto"
|
||||
>
|
||||
<option disabled aria-hidden="true">
|
||||
<option disabled aria-hidden="true" selected>
|
||||
{I18NextService.i18n.t("interface_language")}
|
||||
</option>
|
||||
<option value="browser">
|
||||
|
@ -1451,6 +1460,12 @@ export class Settings extends Component<any, SettingsState> {
|
|||
const newLang = event.target.value ?? "browser";
|
||||
I18NextService.i18n.changeLanguage(
|
||||
newLang === "browser" ? navigator.languages : newLang,
|
||||
() => {
|
||||
// Now the language is loaded, can be synchronous. Let the state update first.
|
||||
window.requestAnimationFrame(() => {
|
||||
i.forceUpdate();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
i.setState(
|
||||
|
@ -1549,6 +1564,7 @@ export class Settings extends Component<any, SettingsState> {
|
|||
});
|
||||
|
||||
UserService.Instance.myUserInfo = siteRes.data.my_user;
|
||||
loadUserLanguage();
|
||||
}
|
||||
|
||||
toast(I18NextService.i18n.t("saved"));
|
||||
|
|
49
src/shared/dynamic-imports.ts
Normal file
49
src/shared/dynamic-imports.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { verifyTranslationImports } from "./services/I18NextService";
|
||||
import { verifyDateFnsImports } from "@utils/app/setup-date-fns";
|
||||
|
||||
export class ImportReport {
|
||||
error: Array<{ id: string; error: Error | string | undefined }> = [];
|
||||
success: string[] = [];
|
||||
}
|
||||
|
||||
export type ImportReportCollection = {
|
||||
translation?: ImportReport;
|
||||
"date-fns"?: ImportReport;
|
||||
};
|
||||
|
||||
function collect(
|
||||
verbose: boolean,
|
||||
kind: keyof ImportReportCollection,
|
||||
collection: ImportReportCollection,
|
||||
report: ImportReport,
|
||||
) {
|
||||
collection[kind] = report;
|
||||
if (verbose) {
|
||||
for (const { id, error } of report.error) {
|
||||
console.warn(`${kind} "${id}" failed: ${error}`);
|
||||
}
|
||||
const good = report.success.length;
|
||||
const bad = report.error.length;
|
||||
if (bad) {
|
||||
console.error(`${bad} out of ${bad + good} ${kind} imports failed.`);
|
||||
} else {
|
||||
console.log(`${good} ${kind} imports verified.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This verifies that the parameters used for parameterized imports are
|
||||
// correct, that the respective chunks are reachable or bundled, and that the
|
||||
// returned objects match expectations.
|
||||
export async function verifyDynamicImports(
|
||||
verbose: boolean,
|
||||
): Promise<ImportReportCollection> {
|
||||
const collection: ImportReportCollection = {};
|
||||
await verifyTranslationImports().then(report =>
|
||||
collect(verbose, "translation", collection, report),
|
||||
);
|
||||
await verifyDateFnsImports().then(report =>
|
||||
collect(verbose, "date-fns", collection, report),
|
||||
);
|
||||
return collection;
|
||||
}
|
|
@ -21,6 +21,7 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
|
|||
declare global {
|
||||
interface Window {
|
||||
isoData: IsoData;
|
||||
checkLazyScripts?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,74 +1,117 @@
|
|||
import { isBrowser } from "@utils/browser";
|
||||
import i18next, { Resource } from "i18next";
|
||||
import i18next, { BackendModule, ReadCallback, Resource } from "i18next";
|
||||
import { ImportReport } from "../dynamic-imports";
|
||||
import { UserService } from "../services";
|
||||
import { ar } from "../translations/ar";
|
||||
import { bg } from "../translations/bg";
|
||||
import { ca } from "../translations/ca";
|
||||
import { cs } from "../translations/cs";
|
||||
import { da } from "../translations/da";
|
||||
import { de } from "../translations/de";
|
||||
import { el } from "../translations/el";
|
||||
import { en } from "../translations/en";
|
||||
import { eo } from "../translations/eo";
|
||||
import { es } from "../translations/es";
|
||||
import { eu } from "../translations/eu";
|
||||
import { fa } from "../translations/fa";
|
||||
import { fi } from "../translations/fi";
|
||||
import { fr } from "../translations/fr";
|
||||
import { ga } from "../translations/ga";
|
||||
import { gl } from "../translations/gl";
|
||||
import { hr } from "../translations/hr";
|
||||
import { id } from "../translations/id";
|
||||
import { it } from "../translations/it";
|
||||
import { ja } from "../translations/ja";
|
||||
import { ko } from "../translations/ko";
|
||||
import { nl } from "../translations/nl";
|
||||
import { oc } from "../translations/oc";
|
||||
import { pl } from "../translations/pl";
|
||||
import { pt } from "../translations/pt";
|
||||
import { pt_BR } from "../translations/pt_BR";
|
||||
import { ru } from "../translations/ru";
|
||||
import { sv } from "../translations/sv";
|
||||
import { vi } from "../translations/vi";
|
||||
import { zh } from "../translations/zh";
|
||||
import { zh_Hant } from "../translations/zh_Hant";
|
||||
import { setupDateFns } from "@utils/app";
|
||||
|
||||
export const languages = [
|
||||
{ resource: ar, code: "ar", name: "العربية" },
|
||||
{ resource: bg, code: "bg", name: "Български" },
|
||||
{ resource: ca, code: "ca", name: "Català" },
|
||||
{ resource: cs, code: "cs", name: "Česky" },
|
||||
{ resource: da, code: "da", name: "Dansk" },
|
||||
{ resource: de, code: "de", name: "Deutsch" },
|
||||
{ resource: el, code: "el", name: "Ελληνικά" },
|
||||
{ resource: en, code: "en", name: "English" },
|
||||
{ resource: eo, code: "eo", name: "Esperanto" },
|
||||
{ resource: es, code: "es", name: "Español" },
|
||||
{ resource: eu, code: "eu", name: "Euskara" },
|
||||
{ resource: fa, code: "fa", name: "فارسی" },
|
||||
{ resource: fi, code: "fi", name: "Suomi" },
|
||||
{ resource: fr, code: "fr", name: "Français" },
|
||||
{ resource: ga, code: "ga", name: "Gaeilge" },
|
||||
{ resource: gl, code: "gl", name: "Galego" },
|
||||
{ resource: hr, code: "hr", name: "Hrvatski" },
|
||||
{ resource: id, code: "id", name: "Bahasa Indonesia" },
|
||||
{ resource: it, code: "it", name: "Italiano" },
|
||||
{ resource: ja, code: "ja", name: "日本語" },
|
||||
{ resource: ko, code: "ko", name: "한국어" },
|
||||
{ resource: nl, code: "nl", name: "Nederlands" },
|
||||
{ resource: oc, code: "oc", name: "Occitan" },
|
||||
{ resource: pl, code: "pl", name: "Polski" },
|
||||
{ resource: pt, code: "pt", name: "Português" },
|
||||
{ resource: pt_BR, code: "pt_BR", name: "Português (Brasil)" },
|
||||
{ resource: ru, code: "ru", name: "Русский" },
|
||||
{ resource: sv, code: "sv", name: "Svenska" },
|
||||
{ resource: vi, code: "vi", name: "Tiếng Việt" },
|
||||
{ resource: zh, code: "zh", name: "中文 (简体)" },
|
||||
{ resource: zh_Hant, code: "zh-TW", name: "中文 (繁體)" },
|
||||
export type TranslationDesc = {
|
||||
resource: string;
|
||||
code: string;
|
||||
name: string;
|
||||
bundled?: boolean;
|
||||
};
|
||||
|
||||
export const languages: TranslationDesc[] = [
|
||||
{ resource: "ar", code: "ar", name: "العربية" },
|
||||
{ resource: "bg", code: "bg", name: "Български" },
|
||||
{ resource: "ca", code: "ca", name: "Català" },
|
||||
{ resource: "cs", code: "cs", name: "Česky" },
|
||||
{ resource: "da", code: "da", name: "Dansk" },
|
||||
{ resource: "de", code: "de", name: "Deutsch" },
|
||||
{ resource: "el", code: "el", name: "Ελληνικά" },
|
||||
{ resource: "en", code: "en", name: "English", bundled: true },
|
||||
{ resource: "eo", code: "eo", name: "Esperanto" },
|
||||
{ resource: "es", code: "es", name: "Español" },
|
||||
{ resource: "eu", code: "eu", name: "Euskara" },
|
||||
{ resource: "fa", code: "fa", name: "فارسی" },
|
||||
{ resource: "fi", code: "fi", name: "Suomi" },
|
||||
{ resource: "fr", code: "fr", name: "Français" },
|
||||
{ resource: "ga", code: "ga", name: "Gaeilge" },
|
||||
{ resource: "gl", code: "gl", name: "Galego" },
|
||||
{ resource: "hr", code: "hr", name: "Hrvatski" },
|
||||
{ resource: "id", code: "id", name: "Bahasa Indonesia" },
|
||||
{ resource: "it", code: "it", name: "Italiano" },
|
||||
{ resource: "ja", code: "ja", name: "日本語" },
|
||||
{ resource: "ko", code: "ko", name: "한국어" },
|
||||
{ resource: "nl", code: "nl", name: "Nederlands" },
|
||||
{ resource: "oc", code: "oc", name: "Occitan" },
|
||||
{ resource: "pl", code: "pl", name: "Polski" },
|
||||
{ resource: "pt", code: "pt", name: "Português" },
|
||||
{ resource: "pt_BR", code: "pt-BR", name: "Português (Brasil)" },
|
||||
{ resource: "ru", code: "ru", name: "Русский" },
|
||||
{ resource: "sv", code: "sv", name: "Svenska" },
|
||||
{ resource: "vi", code: "vi", name: "Tiếng Việt" },
|
||||
{ resource: "zh", code: "zh", name: "中文 (简体)" },
|
||||
{ resource: "zh_Hant", code: "zh-TW", name: "中文 (繁體)" },
|
||||
];
|
||||
|
||||
const resources: Resource = {};
|
||||
languages.forEach(l => (resources[l.code] = l.resource));
|
||||
const languageByCode = languages.reduce((acc, l) => {
|
||||
acc[l.code] = l;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Use pt-BR for users with removed interface language pt_BR.
|
||||
languageByCode["pt_BR"] = languageByCode["pt-BR"];
|
||||
|
||||
async function load(translation: TranslationDesc): Promise<Resource> {
|
||||
const { resource } = translation;
|
||||
return import(
|
||||
/* webpackChunkName: `translation-[request]` */
|
||||
`../translations/${resource}`
|
||||
).then(x => x[resource]);
|
||||
}
|
||||
|
||||
export async function verifyTranslationImports(): Promise<ImportReport> {
|
||||
const report = new ImportReport();
|
||||
const promises = languages.map(lang =>
|
||||
load(lang)
|
||||
.then(x => {
|
||||
if (x && x["translation"]) {
|
||||
report.success.push(lang.code);
|
||||
} else {
|
||||
throw "unexpected format";
|
||||
}
|
||||
})
|
||||
.catch(err => report.error.push({ id: lang.code, error: err })),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function pickTranslations(lang: string): TranslationDesc[] | undefined {
|
||||
const primary = languageByCode[lang];
|
||||
const [head] = (primary?.code ?? lang).split("-");
|
||||
const secondary = head !== lang ? languageByCode[head] : undefined;
|
||||
if (primary && secondary) {
|
||||
return [primary, secondary];
|
||||
} else if (primary) {
|
||||
return [primary];
|
||||
} else if (secondary) {
|
||||
return [secondary];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findTranslationChunkNames(
|
||||
languages: readonly string[],
|
||||
): string[] {
|
||||
for (const lang of languages) {
|
||||
const translations = pickTranslations(lang);
|
||||
if (!translations) {
|
||||
continue;
|
||||
}
|
||||
return translations
|
||||
.filter(x => !x.bundled)
|
||||
.map(x => `translation-${x.resource}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function loadUserLanguage() {
|
||||
await new Promise(r => I18NextService.i18n.changeLanguage(undefined, r));
|
||||
await setupDateFns();
|
||||
}
|
||||
|
||||
function format(value: any, format: any): any {
|
||||
return format === "uppercase" ? value.toUpperCase() : value;
|
||||
|
@ -78,17 +121,53 @@ class LanguageDetector {
|
|||
static readonly type = "languageDetector";
|
||||
|
||||
detect() {
|
||||
const langs: string[] = [];
|
||||
return LanguageService.userLanguages;
|
||||
}
|
||||
}
|
||||
|
||||
export class LanguageService {
|
||||
private static _serverLanguages: readonly string[] = [];
|
||||
private static get languages(): readonly string[] {
|
||||
if (isBrowser()) {
|
||||
return navigator.languages;
|
||||
} else {
|
||||
return this._serverLanguages;
|
||||
}
|
||||
}
|
||||
static updateLanguages(languages: readonly string[]) {
|
||||
this._serverLanguages = languages;
|
||||
I18NextService.i18n.changeLanguage();
|
||||
setupDateFns();
|
||||
}
|
||||
static get userLanguages(): readonly string[] {
|
||||
const myLang =
|
||||
UserService.Instance.myUserInfo?.local_user_view.local_user
|
||||
.interface_language ?? "browser";
|
||||
if (myLang === "browser") {
|
||||
return this.languages;
|
||||
}
|
||||
return [myLang, ...this.languages];
|
||||
}
|
||||
}
|
||||
|
||||
if (myLang !== "browser") langs.push(myLang);
|
||||
class LazyLoader implements Omit<BackendModule, "type"> {
|
||||
static readonly type = "backend";
|
||||
|
||||
if (isBrowser()) langs.push(...navigator.languages);
|
||||
init() {}
|
||||
|
||||
return langs;
|
||||
read(language: string, namespace: string, cb: ReadCallback): void {
|
||||
const translation: TranslationDesc = languageByCode[language];
|
||||
if (!translation) {
|
||||
cb(new Error(`No translation found: ${language} ${namespace}`), false);
|
||||
return;
|
||||
}
|
||||
load(translation)
|
||||
.then(data => {
|
||||
const resKeys = data && data[namespace];
|
||||
if (!resKeys) throw Error(`Failed loading: ${language} ${namespace}`);
|
||||
cb(null, resKeys);
|
||||
})
|
||||
.catch(err => cb(err, false));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,16 +177,20 @@ export class I18NextService {
|
|||
|
||||
private constructor() {
|
||||
this.#i18n = i18next;
|
||||
this.#i18n.use(LanguageDetector).init({
|
||||
this.#i18n
|
||||
.use(LanguageDetector)
|
||||
.use(LazyLoader)
|
||||
.init({
|
||||
debug: false,
|
||||
compatibilityJSON: "v3",
|
||||
supportedLngs: languages.map(l => l.code),
|
||||
nonExplicitSupportedLngs: true,
|
||||
// load: 'languageOnly',
|
||||
load: "all",
|
||||
// initImmediate: false,
|
||||
fallbackLng: "en",
|
||||
resources,
|
||||
resources: { en } as Resource,
|
||||
interpolation: { format },
|
||||
partialBundledLanguages: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export { FirstLoadService } from "./FirstLoadService";
|
||||
export { HttpService } from "./HttpService";
|
||||
export { I18NextService } from "./I18NextService";
|
||||
export { I18NextService, LanguageService } from "./I18NextService";
|
||||
export { UserService } from "./UserService";
|
||||
export { UnreadCounterService } from "./UnreadCounterService";
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import { GetSiteResponse } from "lemmy-js-client";
|
||||
import { setupEmojiDataModel, setupMarkdown } from "../../markdown";
|
||||
import { I18NextService, UserService } from "../../services";
|
||||
import { UserService } from "../../services";
|
||||
import { updateDataBsTheme } from "@utils/browser";
|
||||
|
||||
export default function initializeSite(site?: GetSiteResponse) {
|
||||
UserService.Instance.myUserInfo = site?.my_user;
|
||||
updateDataBsTheme(site);
|
||||
I18NextService.i18n.changeLanguage();
|
||||
if (site) {
|
||||
setupEmojiDataModel(site.custom_emojis ?? []);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,121 @@
|
|||
import { setDefaultOptions } from "date-fns";
|
||||
import { I18NextService } from "../../services";
|
||||
import { setDefaultOptions, Locale } from "date-fns";
|
||||
import {
|
||||
I18NextService,
|
||||
LanguageService,
|
||||
pickTranslations,
|
||||
} from "../../services/I18NextService";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import { ImportReport } from "../../dynamic-imports";
|
||||
|
||||
type DateFnsDesc = { resource: string; code: string; bundled?: boolean };
|
||||
|
||||
const locales: DateFnsDesc[] = [
|
||||
{ resource: "af", code: "af" },
|
||||
{ resource: "ar", code: "ar" },
|
||||
{ resource: "ar-DZ", code: "ar-DZ" },
|
||||
{ resource: "ar-EG", code: "ar-EG" },
|
||||
{ resource: "ar-MA", code: "ar-MA" },
|
||||
{ resource: "ar-SA", code: "ar-SA" },
|
||||
{ resource: "ar-TN", code: "ar-TN" },
|
||||
{ resource: "az", code: "az" },
|
||||
{ resource: "be", code: "be" },
|
||||
{ resource: "be-tarask", code: "be-tarask" },
|
||||
{ resource: "bg", code: "bg" },
|
||||
{ resource: "bn", code: "bn" },
|
||||
{ resource: "bs", code: "bs" },
|
||||
{ resource: "ca", code: "ca" },
|
||||
{ resource: "cs", code: "cs" },
|
||||
{ resource: "cy", code: "cy" },
|
||||
{ resource: "da", code: "da" },
|
||||
{ resource: "de", code: "de" },
|
||||
{ resource: "de-AT", code: "de-AT" },
|
||||
{ resource: "el", code: "el" },
|
||||
{ resource: "en-AU", code: "en-AU" },
|
||||
{ resource: "en-CA", code: "en-CA" },
|
||||
{ resource: "en-GB", code: "en-GB" },
|
||||
{ resource: "en-IE", code: "en-IE" },
|
||||
{ resource: "en-IN", code: "en-IN" },
|
||||
{ resource: "en-NZ", code: "en-NZ" },
|
||||
{ resource: "en-US", code: "en-US", bundled: true },
|
||||
{ resource: "en-ZA", code: "en-ZA" },
|
||||
{ resource: "eo", code: "eo" },
|
||||
{ resource: "es", code: "es" },
|
||||
{ resource: "et", code: "et" },
|
||||
{ resource: "eu", code: "eu" },
|
||||
{ resource: "fa-IR", code: "fa-IR" },
|
||||
{ resource: "fi", code: "fi" },
|
||||
{ resource: "fr", code: "fr" },
|
||||
{ resource: "fr-CA", code: "fr-CA" },
|
||||
{ resource: "fr-CH", code: "fr-CH" },
|
||||
{ resource: "fy", code: "fy" },
|
||||
{ resource: "gd", code: "gd" },
|
||||
{ resource: "gl", code: "gl" },
|
||||
{ resource: "gu", code: "gu" },
|
||||
{ resource: "he", code: "he" },
|
||||
{ resource: "hi", code: "hi" },
|
||||
{ resource: "hr", code: "hr" },
|
||||
{ resource: "ht", code: "ht" },
|
||||
{ resource: "hu", code: "hu" },
|
||||
{ resource: "hy", code: "hy" },
|
||||
{ resource: "id", code: "id" },
|
||||
{ resource: "is", code: "is" },
|
||||
{ resource: "it", code: "it" },
|
||||
{ resource: "it-CH", code: "it-CH" },
|
||||
{ resource: "ja", code: "ja" },
|
||||
{ resource: "ja-Hira", code: "ja-Hira" },
|
||||
{ resource: "ka", code: "ka" },
|
||||
{ resource: "kk", code: "kk" },
|
||||
{ resource: "km", code: "km" },
|
||||
{ resource: "kn", code: "kn" },
|
||||
{ resource: "ko", code: "ko" },
|
||||
{ resource: "lb", code: "lb" },
|
||||
{ resource: "lt", code: "lt" },
|
||||
{ resource: "lv", code: "lv" },
|
||||
{ resource: "mk", code: "mk" },
|
||||
{ resource: "mn", code: "mn" },
|
||||
{ resource: "ms", code: "ms" },
|
||||
{ resource: "mt", code: "mt" },
|
||||
{ resource: "nb", code: "nb" },
|
||||
{ resource: "nl", code: "nl" },
|
||||
{ resource: "nl-BE", code: "nl-BE" },
|
||||
{ resource: "nn", code: "nn" },
|
||||
{ resource: "oc", code: "oc" },
|
||||
{ resource: "pl", code: "pl" },
|
||||
{ resource: "pt", code: "pt" },
|
||||
{ resource: "pt-BR", code: "pt-BR" },
|
||||
{ resource: "ro", code: "ro" },
|
||||
{ resource: "ru", code: "ru" },
|
||||
{ resource: "sk", code: "sk" },
|
||||
{ resource: "sl", code: "sl" },
|
||||
{ resource: "sq", code: "sq" },
|
||||
{ resource: "sr", code: "sr" },
|
||||
{ resource: "sr-Latn", code: "sr-Latn" },
|
||||
{ resource: "sv", code: "sv" },
|
||||
{ resource: "ta", code: "ta" },
|
||||
{ resource: "te", code: "te" },
|
||||
{ resource: "th", code: "th" },
|
||||
{ resource: "tr", code: "tr" },
|
||||
{ resource: "ug", code: "ug" },
|
||||
{ resource: "uk", code: "uk" },
|
||||
{ resource: "uz", code: "uz" },
|
||||
{ resource: "uz-Cyrl", code: "uz-Cyrl" },
|
||||
{ resource: "vi", code: "vi" },
|
||||
{ resource: "zh-CN", code: "zh-CN" },
|
||||
{ resource: "zh-HK", code: "zh-HK" },
|
||||
{ resource: "zh-TW", code: "zh-TW" },
|
||||
];
|
||||
|
||||
const localeByCode = locales.reduce((acc, l) => {
|
||||
acc[l.code] = l;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Use pt-BR for users with removed interface language pt_BR.
|
||||
localeByCode["pt_BR"] = localeByCode["pt-BR"];
|
||||
|
||||
const EN_US = "en-US";
|
||||
|
||||
export default async function () {
|
||||
let lang = I18NextService.i18n.language;
|
||||
function langToLocale(lang: string): DateFnsDesc | undefined {
|
||||
if (lang === "en") {
|
||||
lang = EN_US;
|
||||
}
|
||||
|
@ -19,27 +130,75 @@ export default async function () {
|
|||
}
|
||||
}
|
||||
|
||||
let locale;
|
||||
return localeByCode[lang];
|
||||
}
|
||||
|
||||
try {
|
||||
locale = (
|
||||
await import(
|
||||
/* webpackExclude: /\.js\.flow$/ */
|
||||
`date-fns/locale`
|
||||
)
|
||||
)[lang];
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Could not load locale ${lang} from date-fns, falling back to ${EN_US}`,
|
||||
async function load(locale: DateFnsDesc): Promise<Locale> {
|
||||
return import(
|
||||
/* webpackChunkName: `date-fns-[request]` */
|
||||
`date-fns/locale/${locale.resource}.mjs`
|
||||
).then(x => x.default);
|
||||
}
|
||||
|
||||
export async function verifyDateFnsImports(): Promise<ImportReport> {
|
||||
const report = new ImportReport();
|
||||
const promises = locales.map(locale =>
|
||||
load(locale)
|
||||
.then(x => {
|
||||
if (x && x.code === locale.code) {
|
||||
report.success.push(locale.code);
|
||||
} else {
|
||||
throw "unexpected format";
|
||||
}
|
||||
})
|
||||
.catch(err => report.error.push({ id: locale.code, error: err })),
|
||||
);
|
||||
locale = (
|
||||
await import(
|
||||
/* webpackExclude: /\.js\.flow$/ */
|
||||
`date-fns/locale`
|
||||
)
|
||||
)[EN_US];
|
||||
await Promise.all(promises);
|
||||
return report;
|
||||
}
|
||||
setDefaultOptions({
|
||||
locale,
|
||||
});
|
||||
|
||||
function bestDateFns(
|
||||
languages: readonly string[],
|
||||
i18n_full_lang: string,
|
||||
): DateFnsDesc {
|
||||
const [base_lang] = i18n_full_lang.split("-");
|
||||
for (const lang of languages.filter(x => x.startsWith(base_lang))) {
|
||||
const locale = langToLocale(lang);
|
||||
if (locale) {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
// Fallback to base langauge first, to avoid mixing languages.
|
||||
return langToLocale(base_lang) ?? localeByCode[EN_US];
|
||||
}
|
||||
|
||||
export function findDateFnsChunkNames(languages: readonly string[]): string[] {
|
||||
let i18n_full_lang = EN_US;
|
||||
for (const lang of languages) {
|
||||
if (pickTranslations(lang)) {
|
||||
i18n_full_lang = lang;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const locale = bestDateFns(languages, i18n_full_lang);
|
||||
if (locale.bundled) {
|
||||
return [];
|
||||
}
|
||||
return [`date-fns-${locale.resource}-mjs`];
|
||||
}
|
||||
|
||||
export default async function () {
|
||||
const i18n_full_lang = I18NextService.i18n.resolvedLanguage ?? EN_US;
|
||||
const localeDesc = bestDateFns(LanguageService.userLanguages, i18n_full_lang);
|
||||
try {
|
||||
const locale = await load(localeDesc);
|
||||
if (locale) {
|
||||
setDefaultOptions({ locale });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
console.error(`Loading ${localeDesc.code} date-fns failed.`);
|
||||
}
|
||||
|
||||
setDefaultOptions({ locale: enUS });
|
||||
}
|
||||
|
|
|
@ -63,6 +63,12 @@ module.exports = (env, argv) => {
|
|||
new webpack.BannerPlugin({
|
||||
banner,
|
||||
}),
|
||||
// helps import("date-fns/locale/${x}.mjs") find "date-fns/locale"
|
||||
new webpack.ContextReplacementPlugin(
|
||||
/date-fns\/locale/,
|
||||
resolve(__dirname, "node_modules/date-fns/locale"),
|
||||
false,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -73,6 +79,7 @@ module.exports = (env, argv) => {
|
|||
...base.output,
|
||||
filename: "js/server.js",
|
||||
publicPath: "/",
|
||||
chunkLoading: false, // everything bundled
|
||||
},
|
||||
target: "node",
|
||||
externals: [nodeExternals(), "inferno-helmet"],
|
||||
|
@ -85,6 +92,7 @@ module.exports = (env, argv) => {
|
|||
...base.output,
|
||||
filename: "js/client.js",
|
||||
publicPath: `/static/${env.COMMIT_HASH}/`,
|
||||
chunkFilename: "js/[name].client.js", // predictable names for manual preload
|
||||
},
|
||||
plugins: [
|
||||
...base.plugins,
|
||||
|
|
Loading…
Reference in a new issue