diff --git a/packages/backend/package.json b/packages/backend/package.json index 502323bf61..5254c13b88 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -235,6 +235,7 @@ "jest-mock": "29.7.0", "nodemon": "3.1.9", "pid-port": "1.0.2", - "simple-oauth2": "5.1.0" + "simple-oauth2": "5.1.0", + "vite": "6.2.1" } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 32ea700748..688455f4d6 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -5,10 +5,13 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import * as Sentry from '@sentry/node'; +import locale from '../../../locales/index.js'; import type { RedisOptions } from 'ioredis'; +import type { Manifest, ManifestChunk } from 'vite'; +import type { ILocale } from '../../../locales/index.js'; type RedisOptionsSource = Partial & { host: string; @@ -185,9 +188,12 @@ export type Config = { authUrl: string; driveUrl: string; userAgent: string; - frontendEntry: string; + localeEntries: Record; + errorLocaleMessages: Record; + configEntry: ManifestChunk; + frontendEntry: ManifestChunk; frontendManifestExists: boolean; - frontendEmbedEntry: string; + frontendEmbedEntry: ManifestChunk; frontendEmbedManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; @@ -229,12 +235,23 @@ export function loadConfig(): Config { const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); - const frontendManifest = frontendManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) - : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; - const frontendEmbedManifest = frontendEmbedManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) - : { 'src/boot.ts': { file: 'src/boot.ts' } }; + const frontendManifest: Manifest = frontendManifestExists + ? JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) + : Object.entries(locale).reduce>((a, [k]) => { + a[`locale:${k}`] = { file: `locale:${k}` }; + return a; + }, { + 'src/_boot_.ts': { file: 'src/_boot_.ts' }, + '../frontend-shared/js/config.ts': { file: join('@fs', _dirname.slice(1), '../../frontend-shared/js/config.ts') }, + }); + const frontendEmbedManifest: Manifest = frontendEmbedManifestExists + ? JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + : Object.entries(locale).reduce>((a, [k]) => { + a[`locale:${k}`] = { file: `locale:${k}` }; + return a; + }, { + 'src/boot.ts': { file: 'src/boot.ts' }, + }); const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; @@ -310,6 +327,20 @@ export function loadConfig(): Config { config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator : null, userAgent: `Misskey/${version} (${config.url})`, + localeEntries: Object.entries(frontendManifest).reduce>((a, [k, v]) => { + if (k.startsWith('locale:')) { + a[k.slice('locale:'.length)] = v.file; + } + return a; + }, {}), + errorLocaleMessages: Object.entries(locale).reduce>((a, [k, v]) => { + a[k] = { + _bootErrors: v._bootErrors, + reload: v.reload, + }; + return a; + }, {}), + configEntry: frontendManifest['../frontend-shared/js/config.ts'], frontendEntry: frontendManifest['src/_boot_.ts'], frontendManifestExists: frontendManifestExists, frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'], diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index 9de1275380..bfb7418148 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -32,56 +32,24 @@ } //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { - const supportedLangs = LANGS; - let lang = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - - // Fallback - if (lang == null) lang = 'en-US'; - } - } - - const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (metaRes.status !== 200) { - renderError('META_FETCH'); - return; - } - const meta = await metaRes.json(); - const v = meta.version; - if (v == null) { - renderError('META_FETCH_V'); - return; - } - - // for https://github.com/misskey-dev/misskey/issues/10202 - if (lang == null || lang.toString == null || lang.toString() === 'null') { - console.error('invalid lang value detected!!!', typeof lang, lang); - lang = 'en-US'; - } - - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); - if (localRes.status === 200) { - localStorage.setItem('lang', lang); - localStorage.setItem('locale', await localRes.text()); - localStorage.setItem('localeVersion', v); + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (!supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; } else { - renderError('LOCALE_FETCH'); - return; + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; } } + + await import(`/vite/${LOCALES[lang]}`) + .catch(async e => { + console.error(e); + renderError('LOCALE_FETCH', e); + }); //#endregion //#region Script @@ -115,10 +83,21 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } - const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (!supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - const title = locale?._bootErrors?.title || 'Failed to initialize Misskey'; - const reload = locale?.reload || 'Reload'; + // Fallback + if (lang == null) lang = 'en-US'; + } + } + const { locale } = await import(`/vite/${CONFIG_ENTRY}`).catch(() => ({ locale: {} })); + const title = locale._bootErrors.title; + const reload = locale.reload; document.body.innerHTML = `
${title}
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index b55d327f86..792ba452a8 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -23,56 +23,24 @@ } //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { - const supportedLangs = LANGS; - let lang = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - - // Fallback - if (lang == null) lang = 'en-US'; - } - } - - const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (metaRes.status !== 200) { - renderError('META_FETCH'); - return; - } - const meta = await metaRes.json(); - const v = meta.version; - if (v == null) { - renderError('META_FETCH_V'); - return; - } - - // for https://github.com/misskey-dev/misskey/issues/10202 - if (lang == null || lang.toString == null || lang.toString() === 'null') { - console.error('invalid lang value detected!!!', typeof lang, lang); - lang = 'en-US'; - } - - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); - if (localRes.status === 200) { - localStorage.setItem('lang', lang); - localStorage.setItem('locale', await localRes.text()); - localStorage.setItem('localeVersion', v); + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (!supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; } else { - renderError('LOCALE_FETCH'); - return; + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; } } + + await import(`/vite/${LOCALES[lang]}`) + .catch(async e => { + console.error(e); + renderError('LOCALE_FETCH', e); + }); //#endregion //#region Script @@ -151,21 +119,25 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } - const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (!supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - const messages = Object.assign({ - title: 'Failed to initialize Misskey', - solution: 'The following actions may solve the problem.', - solution1: 'Update your os and browser', - solution2: 'Disable an adblocker', - solution3: 'Clear the browser cache', - solution4: '(Tor Browser) Set dom.webaudio.enabled to true', - otherOption: 'Other options', - otherOption1: 'Clear preferences and cache', - otherOption2: 'Start the simple client', - otherOption3: 'Start the repair tool', - }, locale?._bootErrors || {}); - const reload = locale?.reload || 'Reload'; + // Fallback + if (lang == null) lang = 'en-US'; + } + } + const { locale } = await import(`/vite/${CONFIG_ENTRY}`).catch(() => ({ + locale: { + _bootErrors: {}, + }, + })); + const messages = locale._bootErrors; + const reload = locale.reload; let errorsElement = document.getElementById('errors'); diff --git a/packages/backend/src/server/web/error.js b/packages/backend/src/server/web/error.js index 4838dd6ef3..b0208055a2 100644 --- a/packages/backend/src/server/web/error.js +++ b/packages/backend/src/server/web/error.js @@ -6,23 +6,22 @@ 'use strict'; (() => { - document.addEventListener('DOMContentLoaded', () => { - const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + document.addEventListener('DOMContentLoaded', async () => { + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (!supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - const messages = Object.assign({ - title: 'Failed to initialize Misskey', - serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.', - solution: 'The following actions may solve the problem.', - solution1: 'Update your os and browser', - solution2: 'Disable an adblocker', - solution3: 'Clear the browser cache', - solution4: '(Tor Browser) Set dom.webaudio.enabled to true', - otherOption: 'Other options', - otherOption1: 'Clear preferences and cache', - otherOption2: 'Start the simple client', - otherOption3: 'Start the repair tool', - }, locale?._bootErrors || {}); - const reload = locale?.reload || 'Reload'; + // Fallback + if (lang == null) lang = 'en-US'; + } + } + const locale = ERROR_MESSAGES[lang]; + const messages = locale._bootErrors; + const reload = locale.reload; const reloadEls = document.querySelectorAll('[data-i18n-reload]'); for (const el of reloadEls) { diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug index baa0909676..39b292994f 100644 --- a/packages/backend/src/server/web/views/base-embed.pug +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -41,6 +41,8 @@ html(class='embed') script. var VERSION = "#{version}"; var CLIENT_ENTRY = "#{entry.file}"; + var CONFIG_ENTRY = "#{config.configEntry.file}"; + var LOCALES = JSON.parse(`!{JSON.stringify(config.localeEntries)}`); script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 3883b5e5ab..3efed4b0fc 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -70,6 +70,8 @@ html script. var VERSION = "#{version}"; var CLIENT_ENTRY = "#{entry.file}"; + var CONFIG_ENTRY = "#{config.configEntry.file}"; + var LOCALES = JSON.parse(`!{JSON.stringify(config.localeEntries)}`); script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug index 6a78d1878c..9ed33048a9 100644 --- a/packages/backend/src/server/web/views/error.pug +++ b/packages/backend/src/server/web/views/error.pug @@ -27,6 +27,9 @@ html style include ../error.css + script. + var ERROR_MESSAGES = JSON.parse(`!{JSON.stringify(config.errorLocaleMessages)}`); + script include ../error.js diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index c1b2b58beb..a8d2002d38 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -13,18 +13,17 @@ import { createApp, defineAsyncComponent } from 'vue'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-dark.json5'; import { MediaProxy } from '@@/js/media-proxy.js'; +import { url, version, locale, lang, updateLocale } from '@@/js/config.js'; +import { parseEmbedParams } from '@@/js/embed-page.js'; +import type { Theme } from '@/theme.js'; import { applyTheme, assertIsTheme } from '@/theme.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { DI } from '@/di.js'; import { serverMetadata } from '@/server-metadata.js'; -import { url, version, locale, lang, updateLocale } from '@@/js/config.js'; -import { parseEmbedParams } from '@@/js/embed-page.js'; import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; import { serverContext } from '@/server-context.js'; import { i18n, updateI18n } from '@/i18n.js'; -import type { Theme } from '@/theme.js'; - console.log('Misskey Embed'); //#region Embedパラメータの取得・パース @@ -71,22 +70,6 @@ if (embedParams.colorMode === 'dark') { } //#endregion -//#region Detect language & fetch translations -const localeVersion = localStorage.getItem('localeVersion'); -const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); -if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - localStorage.setItem('locale', newLocale); - localStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } -} -//#endregion - // サイズの制限 document.documentElement.style.maxWidth = '500px'; diff --git a/packages/frontend-shared/js/config.ts b/packages/frontend-shared/js/config.ts index 26dd36d6c3..56b8d2f2fc 100644 --- a/packages/frontend-shared/js/config.ts +++ b/packages/frontend-shared/js/config.ts @@ -17,8 +17,7 @@ export const apiUrl = location.origin + '/api'; export const wsOrigin = location.origin; export const lang = localStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; -const preParseLocale = localStorage.getItem('locale'); -export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null; +export let locale: Locale; export const version = _VERSION_; export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName; export const ui = localStorage.getItem('ui'); diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index 269bc4fb9a..368b67688a 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -55,7 +55,6 @@ function initLocalStorage() { ...userDetailed(), policies: {}, })); - localStorage.setItem('locale', JSON.stringify(locale)); } initialize({ @@ -70,13 +69,17 @@ queueMicrotask(() => { import('../src/theme.js'), import('../src/preferences.js'), import('../src/os.js'), - ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os]) => { + import('../src/i18n.js'), + import('../../frontend-shared/js/config.js'), + ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { prefer }, os, { updateI18n }, { updateLocale }]) => { setup((app) => { moduleInitialized = true; if (app[appInitialized]) { return; } app[appInitialized] = true; + updateLocale(locale); + updateI18n(locale); loadTheme(applyTheme); components(app); directives(app); diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 9a505ca9f8..0851155a71 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -78,22 +78,6 @@ export async function common(createVue: () => App) { } //#endregion - //#region Detect language & fetch translations - const localeVersion = miLocalStorage.getItem('localeVersion'); - const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); - if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - miLocalStorage.setItem('locale', newLocale); - miLocalStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } - } - //#endregion - // タッチデバイスでCSSの:hoverを機能させる window.document.addEventListener('touchend', () => {}, { passive: true }); diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 099339fbee..2a28a58f37 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -23,8 +23,8 @@ export type Keys = ( 'fontSize' | 'ui' | 'ui_temp' | - 'locale' | - 'localeVersion' | + 'locale' | // DEPRECATED + 'localeVersion' | // DEPRECATED 'theme' | 'themeId' | 'customCss' | diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index 9256a565c4..dcd3d1a812 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -606,8 +606,6 @@ const defaultFollowWithReplies = prefer.model('defaultFollowWithReplies'); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); }); watch([ diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts index b6ae254727..0969fcdf14 100644 --- a/packages/frontend/src/utility/clear-cache.ts +++ b/packages/frontend/src/utility/clear-cache.ts @@ -13,8 +13,10 @@ export async function clearCache() { os.waiting(); miLocalStorage.removeItem('instance'); miLocalStorage.removeItem('instanceCachedAt'); + //#region deprecated miLocalStorage.removeItem('locale'); miLocalStorage.removeItem('localeVersion'); + //#endregion miLocalStorage.removeItem('theme'); miLocalStorage.removeItem('emojis'); miLocalStorage.removeItem('lastEmojisFetchedAt'); diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index ec80e71ae4..a53f0c2a33 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -99,6 +99,30 @@ export function getConfig(): UserConfig { pluginVue(), pluginUnwindCssModuleClassName(), pluginJson5(), + { + name: 'misskey:locale', + load: { + async handler(id) { + if (id.startsWith('locale:')) { + const locale = id.slice('locale:'.length); + return ` + import { updateLocale } from '@@/js/config.js'; + updateLocale(JSON.parse(${JSON.stringify(JSON.stringify(locales[locale]))})); + `; + } + }, + }, + resolveId: { + async handler(source, importer, options) { + if (source.startsWith('locale:')) { + return source; + } + if (importer === path.resolve(__dirname, 'index.html') && source.startsWith('/locale:')) { + return source.slice(1); + } + }, + }, + }, ...process.env.NODE_ENV === 'production' ? [ pluginReplace({ @@ -162,9 +186,7 @@ export function getConfig(): UserConfig { ], manifest: 'manifest.json', rollupOptions: { - input: { - app: './src/_boot_.ts', - }, + input: ['@/_boot_.ts', '@@/js/config.ts', ...Object.keys(locales).map(locale => `locale:${locale}`)], external: externalPackages.map(p => p.match), output: { manualChunks: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40596d43ed..64aecbb185 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: simple-oauth2: specifier: 5.1.0 version: 5.1.0 + vite: + specifier: 6.2.1 + version: 6.2.1(@types/node@22.13.4)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3) optionalDependencies: '@swc/core-android-arm64': specifier: 1.3.11 @@ -21846,6 +21849,18 @@ snapshots: vite-plugin-turbosnap@1.0.3: {} + vite@6.2.1(@types/node@22.13.4)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3): + dependencies: + esbuild: 0.25.0 + postcss: 8.5.3 + rollup: 4.34.9 + optionalDependencies: + '@types/node': 22.13.4 + fsevents: 2.3.3 + sass: 1.85.1 + terser: 5.39.0 + tsx: 4.19.3 + vite@6.2.1(@types/node@22.13.9)(sass@1.85.1)(terser@5.39.0)(tsx@4.19.3): dependencies: esbuild: 0.25.0