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:
matc-pub 2024-03-13 21:39:45 +01:00 committed by GitHub
parent c80136ed65
commit e832cd2729
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 495 additions and 117 deletions

View file

@ -1,16 +1,23 @@
import { initializeSite, setupDateFns } from "@utils/app"; import { initializeSite } from "@utils/app";
import { hydrate } from "inferno-hydrate"; import { hydrate } from "inferno-hydrate";
import { BrowserRouter } from "inferno-router"; import { BrowserRouter } from "inferno-router";
import { App } from "../shared/components/app/app"; 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/collapse";
import "bootstrap/js/dist/dropdown"; import "bootstrap/js/dist/dropdown";
import "bootstrap/js/dist/modal"; import "bootstrap/js/dist/modal";
async function startClient() { 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); initializeSite(window.isoData.site_res);
await setupDateFns(); await loadUserLanguage();
const wrapper = ( const wrapper = (
<BrowserRouter> <BrowserRouter>
@ -22,6 +29,8 @@ async function startClient() {
if (root) { if (root) {
hydrate(wrapper, root); hydrate(wrapper, root);
root.dispatchEvent(new CustomEvent("lemmy-hydrated", { bubbles: true }));
} }
} }

View file

@ -20,9 +20,26 @@ import { createSsrHtml } from "../utils/create-ssr-html";
import { getErrorPageData } from "../utils/get-error-page-data"; import { getErrorPageData } from "../utils/get-error-page-data";
import { setForwardedHeaders } from "../utils/set-forwarded-headers"; import { setForwardedHeaders } from "../utils/set-forwarded-headers";
import { getJwtCookie } from "../utils/has-jwt-cookie"; import { getJwtCookie } from "../utils/has-jwt-cookie";
import {
I18NextService,
LanguageService,
UserService,
} from "../../shared/services/";
export default async (req: Request, res: Response) => { export default async (req: Request, res: Response) => {
try { 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 activeRoute = routes.find(route => matchPath(req.path, route));
const headers = setForwardedHeaders(req.headers); const headers = setForwardedHeaders(req.headers);
@ -60,6 +77,7 @@ export default async (req: Request, res: Response) => {
if (try_site.state === "success") { if (try_site.state === "success") {
site = try_site.data; site = try_site.data;
initializeSite(site); initializeSite(site);
LanguageService.updateLanguages(languages);
if (path !== "/setup" && !site.site_view.local_site.site_setup) { if (path !== "/setup" && !site.site_view.local_site.site_setup) {
return res.redirect("/setup"); return res.redirect("/setup");
@ -73,6 +91,16 @@ export default async (req: Request, res: Response) => {
headers, 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); routeData = await activeRoute.fetchInitialData(initialFetchReq);
} }
@ -114,9 +142,20 @@ export default async (req: Request, res: Response) => {
</StaticRouter> </StaticRouter>
); );
// Another request could have initialized a new site.
initializeSite(site);
LanguageService.updateLanguages(languages);
const root = renderToString(wrapper); 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) { } catch (err) {
// If an error is caught here, the error page couldn't even be rendered // If an error is caught here, the error page couldn't even be rendered
console.error(err); console.error(err);

View file

@ -13,6 +13,7 @@ import ThemeHandler from "./handlers/theme-handler";
import ThemesListHandler from "./handlers/themes-list-handler"; import ThemesListHandler from "./handlers/themes-list-handler";
import { setCacheControl, setDefaultCsp } from "./middleware"; import { setCacheControl, setDefaultCsp } from "./middleware";
import CodeThemeHandler from "./handlers/code-theme-handler"; import CodeThemeHandler from "./handlers/code-theme-handler";
import { verifyDynamicImports } from "../shared/dynamic-imports";
const server = express(); const server = express();
@ -54,6 +55,8 @@ server.get("/css/themelist", ThemesListHandler);
server.get("/*", CatchAllHandler); server.get("/*", CatchAllHandler);
const listener = server.listen(Number(port), hostname, () => { const listener = server.listen(Number(port), hostname, () => {
verifyDynamicImports(true);
setupDateFns(); setupDateFns();
console.log( console.log(
`Lemmy-ui v${VERSION} started listening on http://${hostname}:${port}`, `Lemmy-ui v${VERSION} started listening on http://${hostname}:${port}`,

View file

@ -7,6 +7,8 @@ import { favIconPngUrl, favIconUrl } from "../../shared/config";
import { IsoDataOptionalSite } from "../../shared/interfaces"; import { IsoDataOptionalSite } from "../../shared/interfaces";
import { buildThemeList } from "./build-themes-list"; import { buildThemeList } from "./build-themes-list";
import { fetchIconPng } from "./fetch-icon-png"; 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"] || ""; const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
@ -16,6 +18,7 @@ export async function createSsrHtml(
root: string, root: string,
isoData: IsoDataOptionalSite, isoData: IsoDataOptionalSite,
cspNonce: string, cspNonce: string,
userLanguages: readonly string[],
) { ) {
const site = isoData.site_res; const site = isoData.site_res;
@ -63,10 +66,20 @@ export async function createSsrHtml(
const helmet = Helmet.renderStatic(); 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 ` return `
<!DOCTYPE html> <!DOCTYPE html>
<html ${helmet.htmlAttributes.toString()}> <html ${helmet.htmlAttributes.toString()}>
<head> <head>
${lazyScripts}
<script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script> <script nonce="${cspNonce}">window.isoData = ${serialize(isoData)}</script>
<!-- A remote debugging utility for mobile --> <!-- A remote debugging utility for mobile -->

View file

@ -78,7 +78,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe( UnreadCounterService.Instance.unreadApplicationCountSubject.subscribe(
unreadApplicationCount => this.setState({ unreadApplicationCount }), unreadApplicationCount => this.setState({ unreadApplicationCount }),
); );
this.requestNotificationPermission();
document.addEventListener("mouseup", this.handleOutsideMenuClick); document.addEventListener("mouseup", this.handleOutsideMenuClick);
} }
@ -468,7 +467,7 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
requestNotificationPermission() { requestNotificationPermission() {
if (UserService.Instance.myUserInfo) { if (UserService.Instance.myUserInfo) {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("lemmy-hydrated", function () {
if (!Notification) { if (!Notification) {
toast(I18NextService.i18n.t("notifications_error"), "danger"); toast(I18NextService.i18n.t("notifications_error"), "danger");
return; return;

View file

@ -45,7 +45,11 @@ import {
RequestState, RequestState,
wrapClient, wrapClient,
} from "../../services/HttpService"; } from "../../services/HttpService";
import { I18NextService, languages } from "../../services/I18NextService"; import {
I18NextService,
languages,
loadUserLanguage,
} from "../../services/I18NextService";
import { setupTippy } from "../../tippy"; import { setupTippy } from "../../tippy";
import { toast } from "../../toast"; import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags"; 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({ static async fetchInitialData({
headers, headers,
}: InitialFetchRequest): Promise<SettingsData> { }: InitialFetchRequest): Promise<SettingsData> {
@ -791,7 +800,7 @@ export class Settings extends Component<any, SettingsState> {
onChange={linkEvent(this, this.handleInterfaceLangChange)} onChange={linkEvent(this, this.handleInterfaceLangChange)}
className="form-select d-inline-block w-auto" className="form-select d-inline-block w-auto"
> >
<option disabled aria-hidden="true"> <option disabled aria-hidden="true" selected>
{I18NextService.i18n.t("interface_language")} {I18NextService.i18n.t("interface_language")}
</option> </option>
<option value="browser"> <option value="browser">
@ -1451,6 +1460,12 @@ export class Settings extends Component<any, SettingsState> {
const newLang = event.target.value ?? "browser"; const newLang = event.target.value ?? "browser";
I18NextService.i18n.changeLanguage( I18NextService.i18n.changeLanguage(
newLang === "browser" ? navigator.languages : newLang, newLang === "browser" ? navigator.languages : newLang,
() => {
// Now the language is loaded, can be synchronous. Let the state update first.
window.requestAnimationFrame(() => {
i.forceUpdate();
});
},
); );
i.setState( i.setState(
@ -1549,6 +1564,7 @@ export class Settings extends Component<any, SettingsState> {
}); });
UserService.Instance.myUserInfo = siteRes.data.my_user; UserService.Instance.myUserInfo = siteRes.data.my_user;
loadUserLanguage();
} }
toast(I18NextService.i18n.t("saved")); toast(I18NextService.i18n.t("saved"));

View 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;
}

View file

@ -21,6 +21,7 @@ export type IsoDataOptionalSite<T extends RouteData = any> = Partial<
declare global { declare global {
interface Window { interface Window {
isoData: IsoData; isoData: IsoData;
checkLazyScripts?: () => void;
} }
} }

View file

@ -1,74 +1,117 @@
import { isBrowser } from "@utils/browser"; 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 { 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 { en } from "../translations/en";
import { eo } from "../translations/eo"; import { setupDateFns } from "@utils/app";
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";
export const languages = [ export type TranslationDesc = {
{ resource: ar, code: "ar", name: "العربية" }, resource: string;
{ resource: bg, code: "bg", name: "Български" }, code: string;
{ resource: ca, code: "ca", name: "Català" }, name: string;
{ resource: cs, code: "cs", name: "Česky" }, bundled?: boolean;
{ resource: da, code: "da", name: "Dansk" }, };
{ resource: de, code: "de", name: "Deutsch" },
{ resource: el, code: "el", name: "Ελληνικά" }, export const languages: TranslationDesc[] = [
{ resource: en, code: "en", name: "English" }, { resource: "ar", code: "ar", name: "العربية" },
{ resource: eo, code: "eo", name: "Esperanto" }, { resource: "bg", code: "bg", name: "Български" },
{ resource: es, code: "es", name: "Español" }, { resource: "ca", code: "ca", name: "Català" },
{ resource: eu, code: "eu", name: "Euskara" }, { resource: "cs", code: "cs", name: "Česky" },
{ resource: fa, code: "fa", name: "فارسی" }, { resource: "da", code: "da", name: "Dansk" },
{ resource: fi, code: "fi", name: "Suomi" }, { resource: "de", code: "de", name: "Deutsch" },
{ resource: fr, code: "fr", name: "Français" }, { resource: "el", code: "el", name: "Ελληνικά" },
{ resource: ga, code: "ga", name: "Gaeilge" }, { resource: "en", code: "en", name: "English", bundled: true },
{ resource: gl, code: "gl", name: "Galego" }, { resource: "eo", code: "eo", name: "Esperanto" },
{ resource: hr, code: "hr", name: "Hrvatski" }, { resource: "es", code: "es", name: "Español" },
{ resource: id, code: "id", name: "Bahasa Indonesia" }, { resource: "eu", code: "eu", name: "Euskara" },
{ resource: it, code: "it", name: "Italiano" }, { resource: "fa", code: "fa", name: "فارسی" },
{ resource: ja, code: "ja", name: "日本語" }, { resource: "fi", code: "fi", name: "Suomi" },
{ resource: ko, code: "ko", name: "한국어" }, { resource: "fr", code: "fr", name: "Français" },
{ resource: nl, code: "nl", name: "Nederlands" }, { resource: "ga", code: "ga", name: "Gaeilge" },
{ resource: oc, code: "oc", name: "Occitan" }, { resource: "gl", code: "gl", name: "Galego" },
{ resource: pl, code: "pl", name: "Polski" }, { resource: "hr", code: "hr", name: "Hrvatski" },
{ resource: pt, code: "pt", name: "Português" }, { resource: "id", code: "id", name: "Bahasa Indonesia" },
{ resource: pt_BR, code: "pt_BR", name: "Português (Brasil)" }, { resource: "it", code: "it", name: "Italiano" },
{ resource: ru, code: "ru", name: "Русский" }, { resource: "ja", code: "ja", name: "日本語" },
{ resource: sv, code: "sv", name: "Svenska" }, { resource: "ko", code: "ko", name: "한국어" },
{ resource: vi, code: "vi", name: "Tiếng Việt" }, { resource: "nl", code: "nl", name: "Nederlands" },
{ resource: zh, code: "zh", name: "中文 (简体)" }, { resource: "oc", code: "oc", name: "Occitan" },
{ resource: zh_Hant, code: "zh-TW", name: "中文 (繁體)" }, { 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 = {}; const languageByCode = languages.reduce((acc, l) => {
languages.forEach(l => (resources[l.code] = l.resource)); 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 { function format(value: any, format: any): any {
return format === "uppercase" ? value.toUpperCase() : value; return format === "uppercase" ? value.toUpperCase() : value;
@ -78,17 +121,53 @@ class LanguageDetector {
static readonly type = "languageDetector"; static readonly type = "languageDetector";
detect() { 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 = const myLang =
UserService.Instance.myUserInfo?.local_user_view.local_user UserService.Instance.myUserInfo?.local_user_view.local_user
.interface_language ?? "browser"; .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() { private constructor() {
this.#i18n = i18next; this.#i18n = i18next;
this.#i18n.use(LanguageDetector).init({ this.#i18n
.use(LanguageDetector)
.use(LazyLoader)
.init({
debug: false, debug: false,
compatibilityJSON: "v3", compatibilityJSON: "v3",
supportedLngs: languages.map(l => l.code), supportedLngs: languages.map(l => l.code),
nonExplicitSupportedLngs: true, nonExplicitSupportedLngs: true,
// load: 'languageOnly', load: "all",
// initImmediate: false, // initImmediate: false,
fallbackLng: "en", fallbackLng: "en",
resources, resources: { en } as Resource,
interpolation: { format }, interpolation: { format },
partialBundledLanguages: true,
}); });
} }

View file

@ -1,5 +1,5 @@
export { FirstLoadService } from "./FirstLoadService"; export { FirstLoadService } from "./FirstLoadService";
export { HttpService } from "./HttpService"; export { HttpService } from "./HttpService";
export { I18NextService } from "./I18NextService"; export { I18NextService, LanguageService } from "./I18NextService";
export { UserService } from "./UserService"; export { UserService } from "./UserService";
export { UnreadCounterService } from "./UnreadCounterService"; export { UnreadCounterService } from "./UnreadCounterService";

View file

@ -1,12 +1,11 @@
import { GetSiteResponse } from "lemmy-js-client"; import { GetSiteResponse } from "lemmy-js-client";
import { setupEmojiDataModel, setupMarkdown } from "../../markdown"; import { setupEmojiDataModel, setupMarkdown } from "../../markdown";
import { I18NextService, UserService } from "../../services"; import { UserService } from "../../services";
import { updateDataBsTheme } from "@utils/browser"; import { updateDataBsTheme } from "@utils/browser";
export default function initializeSite(site?: GetSiteResponse) { export default function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user; UserService.Instance.myUserInfo = site?.my_user;
updateDataBsTheme(site); updateDataBsTheme(site);
I18NextService.i18n.changeLanguage();
if (site) { if (site) {
setupEmojiDataModel(site.custom_emojis ?? []); setupEmojiDataModel(site.custom_emojis ?? []);
} }

View file

@ -1,10 +1,121 @@
import { setDefaultOptions } from "date-fns"; import { setDefaultOptions, Locale } from "date-fns";
import { I18NextService } from "../../services"; 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"; const EN_US = "en-US";
export default async function () { function langToLocale(lang: string): DateFnsDesc | undefined {
let lang = I18NextService.i18n.language;
if (lang === "en") { if (lang === "en") {
lang = EN_US; lang = EN_US;
} }
@ -19,27 +130,75 @@ export default async function () {
} }
} }
let locale; return localeByCode[lang];
}
try {
locale = ( async function load(locale: DateFnsDesc): Promise<Locale> {
await import( return import(
/* webpackExclude: /\.js\.flow$/ */ /* webpackChunkName: `date-fns-[request]` */
`date-fns/locale` `date-fns/locale/${locale.resource}.mjs`
) ).then(x => x.default);
)[lang]; }
} catch (e) {
console.log( export async function verifyDateFnsImports(): Promise<ImportReport> {
`Could not load locale ${lang} from date-fns, falling back to ${EN_US}`, const report = new ImportReport();
); const promises = locales.map(locale =>
locale = ( load(locale)
await import( .then(x => {
/* webpackExclude: /\.js\.flow$/ */ if (x && x.code === locale.code) {
`date-fns/locale` report.success.push(locale.code);
) } else {
)[EN_US]; throw "unexpected format";
} }
setDefaultOptions({ })
locale, .catch(err => report.error.push({ id: locale.code, error: err })),
}); );
await Promise.all(promises);
return report;
}
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 });
} }

View file

@ -63,6 +63,12 @@ module.exports = (env, argv) => {
new webpack.BannerPlugin({ new webpack.BannerPlugin({
banner, 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, ...base.output,
filename: "js/server.js", filename: "js/server.js",
publicPath: "/", publicPath: "/",
chunkLoading: false, // everything bundled
}, },
target: "node", target: "node",
externals: [nodeExternals(), "inferno-helmet"], externals: [nodeExternals(), "inferno-helmet"],
@ -85,6 +92,7 @@ module.exports = (env, argv) => {
...base.output, ...base.output,
filename: "js/client.js", filename: "js/client.js",
publicPath: `/static/${env.COMMIT_HASH}/`, publicPath: `/static/${env.COMMIT_HASH}/`,
chunkFilename: "js/[name].client.js", // predictable names for manual preload
}, },
plugins: [ plugins: [
...base.plugins, ...base.plugins,