summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authoranatawa12 <anatawa12@icloud.com>2025-08-08 11:26:18 +0900
committerGitHub <noreply@github.com>2025-08-08 11:26:18 +0900
commit8598f3912ecc16f9b7c3f502e09d9ea96f7e507d (patch)
tree73b24c7ab473189a679ae6aa476b6822f812cbed /packages/frontend
parentUpdate CONTRIBUTING.md (diff)
downloadmisskey-8598f3912ecc16f9b7c3f502e09d9ea96f7e507d.tar.gz
misskey-8598f3912ecc16f9b7c3f502e09d9ea96f7e507d.tar.bz2
misskey-8598f3912ecc16f9b7c3f502e09d9ea96f7e507d.zip
per-locale bundle & inline locale (#16369)
* feat: split entry file by locale name * chore: とりあえず transform hook で雑に分割 * chore: とりあえず transform 結果をいい感じに * chore: concurrent buildで高速化 * chore: vite ではローケルのないものをビルドして後処理でどうにかするように * chore: 後処理のためにi18n.jを単体になるように切り出す * chore: use typescript * chore: remove unref(i18n) in vite build process * chore: inline variable * fix: build error * fix: i18n.ts.something.replaceAll() become error * chore: ignore export specifier from error * chore: support i18n.tsx as object * chore: process literal for all files * chore: split config and locale * chore: inline locale name * chore: remove updating locale in boot common * chore: use top-level await to load locales * chore: inline locale * chore: remove loading locale from boot.js * chore: remove loading locale from boot.js * コメント追加 * fix test; fetchに失敗する * import削除ログをdebugレベルに * fix: watch pug * chore: use hash for entry files * chore: remove es-module-lexer from dependencies * chore: move to frontend-builder * chore: use inline locale in embed * chore: refetch json on hot reload * feat: store localization related to boot.js in backend in bootloaderLocales localstorage * 応急処置を戻す * fix spex * fix `Using i18n identifier "e" directly. Skipping inlining.` warning * refactor: use scriptsDir parameter * chore: remove i18n from depmap * chore: make build crash if errors * error -> warn few conditions * use inline object * update localstorage keys * remove accessing locale localstorage * fix: failed to process i18n.tsx.aaa({x:i18n.bbb})
Diffstat (limited to 'packages/frontend')
-rw-r--r--packages/frontend/.gitignore1
-rw-r--r--packages/frontend/.storybook/preview.ts2
-rw-r--r--packages/frontend/build.ts51
-rw-r--r--packages/frontend/package.json4
-rw-r--r--packages/frontend/src/boot/common.ts26
-rw-r--r--packages/frontend/src/components/MkExtensionInstaller.vue6
-rw-r--r--packages/frontend/src/i18n.ts3
-rw-r--r--packages/frontend/src/local-storage.ts3
-rw-r--r--packages/frontend/src/pages/settings/preferences.vue2
-rw-r--r--packages/frontend/src/utility/clear-cache.ts2
-rw-r--r--packages/frontend/tsconfig.json1
-rw-r--r--packages/frontend/vite.config.ts13
12 files changed, 77 insertions, 37 deletions
diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore
index 1aa0ac14e8..4f8684c18e 100644
--- a/packages/frontend/.gitignore
+++ b/packages/frontend/.gitignore
@@ -1 +1,2 @@
/storybook-static
+/build/
diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts
index fb855c1410..2aac8af400 100644
--- a/packages/frontend/.storybook/preview.ts
+++ b/packages/frontend/.storybook/preview.ts
@@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
-import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
import themes from './themes.js';
import '../src/style.scss';
@@ -55,7 +54,6 @@ function initLocalStorage() {
...userDetailed(),
policies: {},
}));
- localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({
diff --git a/packages/frontend/build.ts b/packages/frontend/build.ts
new file mode 100644
index 0000000000..0401c2b9ba
--- /dev/null
+++ b/packages/frontend/build.ts
@@ -0,0 +1,51 @@
+import * as fs from 'fs/promises';
+import url from 'node:url';
+import path from 'node:path';
+import { execa } from 'execa';
+import locales from '../../locales/index.js';
+import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
+import { createLogger } from '../frontend-builder/logger';
+
+// requires node 21 or later
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const outputDir = __dirname + '/../../built/_frontend_vite_';
+
+/**
+ * @return {Promise<void>}
+ */
+async function viteBuild() {
+ await execa('vite', ['build'], {
+ cwd: __dirname,
+ stdout: process.stdout,
+ stderr: process.stderr,
+ });
+}
+
+
+async function buildAllLocale() {
+ const logger = createLogger()
+ const inliner = await LocaleInliner.create({
+ outputDir,
+ logger,
+ scriptsDir: 'scripts',
+ i18nFile: 'src/i18n.ts',
+ })
+
+ await inliner.loadFiles();
+
+ inliner.collectsModifications();
+
+ await inliner.saveAllLocales(locales);
+
+ if (logger.errorCount > 0) {
+ throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
+ }
+}
+
+async function build() {
+ await fs.rm(outputDir, { recursive: true, force: true });
+ await viteBuild();
+ await buildAllLocale();
+}
+
+await build();
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index f4f72d944a..eaefe92612 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"watch": "vite",
- "build": "vite build",
+ "build": "tsx build.ts",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
@@ -47,6 +47,7 @@
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
+ "execa": "9.6.0",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
@@ -137,6 +138,7 @@
"start-server-and-test": "2.0.12",
"storybook": "9.1.0",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
+ "tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4",
"vitest-fetch-mock": "0.4.5",
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index ea41155ab0..395d1e5e7e 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -5,9 +5,10 @@
import { computed, watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
-import { version, lang, updateLocale, locale, apiUrl, isSafeMode } from '@@/js/config.js';
+import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
+import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import type { App } from 'vue';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
@@ -79,25 +80,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
//#endregion
//#region Detect language & fetch translations
- const localeVersion = miLocalStorage.getItem('localeVersion');
- const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
-
- async function fetchAndUpdateLocale({ useCache } = { useCache: true }) {
- const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' };
- const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions);
- 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);
- }
- }
-
- if (localeOutdated) {
- fetchAndUpdateLocale();
- }
+ storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
if (import.meta.hot) {
import.meta.hot.on('locale-update', async (updatedLang: string) => {
@@ -106,7 +89,8 @@ export async function common(createVue: () => Promise<App<Element>>) {
await new Promise(resolve => {
window.setTimeout(resolve, 500);
});
- await fetchAndUpdateLocale({ useCache: false });
+ // fetch with cache: 'no-store' to ensure the latest locale is fetched
+ await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text());
window.location.reload();
}
});
diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue
index a2247d844b..c9d18ee731 100644
--- a/packages/frontend/src/components/MkExtensionInstaller.vue
+++ b/packages/frontend/src/components/MkExtensionInstaller.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo>
- <div v-if="isPlugin" class="_gaps_s">
+ <div v-if="extension.type === 'plugin'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCode :code="extension.raw"/>
</MkFolder>
</div>
- <div v-else-if="isTheme" class="_gaps_s">
+ <div v-else-if="extension.type === 'theme'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
- <template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template>
+ <template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template>
</MkKeyValue>
</div>
</MkFolder>
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts
index 6ad503b089..0b2b206b7e 100644
--- a/packages/frontend/src/i18n.ts
+++ b/packages/frontend/src/i18n.ts
@@ -5,11 +5,12 @@
import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js';
+import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js';
-import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
+// test 以外では使わないこと。インライン化されてるのでだいたい意味がない
export function updateI18n(newLocale: Locale) {
i18n.locale = newLocale;
}
diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts
index b64a8c5dd5..687983bcdb 100644
--- a/packages/frontend/src/local-storage.ts
+++ b/packages/frontend/src/local-storage.ts
@@ -22,8 +22,7 @@ export type Keys = (
'fontSize' |
'ui' |
'ui_temp' |
- 'locale' |
- 'localeVersion' |
+ 'bootloaderLocales' |
'theme' |
'themeId' |
'customCss' |
diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue
index ed4f36c0e5..fb1a409180 100644
--- a/packages/frontend/src/pages/settings/preferences.vue
+++ b/packages/frontend/src/pages/settings/preferences.vue
@@ -886,8 +886,6 @@ const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null);
watch(lang, () => {
miLocalStorage.setItem('lang', lang.value as string);
- miLocalStorage.removeItem('locale');
- miLocalStorage.removeItem('localeVersion');
});
watch(fontSize, () => {
diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts
index b6ae254727..8a62265438 100644
--- a/packages/frontend/src/utility/clear-cache.ts
+++ b/packages/frontend/src/utility/clear-cache.ts
@@ -13,8 +13,6 @@ export async function clearCache() {
os.waiting();
miLocalStorage.removeItem('instance');
miLocalStorage.removeItem('instanceCachedAt');
- miLocalStorage.removeItem('locale');
- miLocalStorage.removeItem('localeVersion');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
index 9c7c611e5b..c445ada034 100644
--- a/packages/frontend/tsconfig.json
+++ b/packages/frontend/tsconfig.json
@@ -46,6 +46,7 @@
},
"compileOnSave": false,
"include": [
+ "./build.ts",
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 7b6bf436b1..e9707d13d1 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -14,6 +14,7 @@ import pluginJson5 from './vite.json5.js';
import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js';
import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js';
import pluginWatchLocales from './lib/vite-plugin-watch-locales.js';
+import pluginRemoveUnrefI18n from '../frontend-builder/rollup-plugin-remove-unref-i18n.js';
const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null;
const host = url ? (new URL(url)).hostname : undefined;
@@ -111,6 +112,7 @@ export function getConfig(): UserConfig {
pluginWatchLocales(),
...searchIndexes.map(options => pluginCreateSearchIndex(options)),
pluginVue(),
+ pluginRemoveUnrefI18n(),
pluginUnwindCssModuleClassName(),
pluginJson5(),
...process.env.NODE_ENV === 'production'
@@ -174,16 +176,21 @@ export function getConfig(): UserConfig {
manifest: 'manifest.json',
rollupOptions: {
input: {
- app: './src/_boot_.ts',
+ i18n: './src/i18n.ts',
+ entry: './src/_boot_.ts',
},
external: externalPackages.map(p => p.match),
+ preserveEntrySignatures: 'allow-extension',
output: {
manualChunks: {
vue: ['vue'],
photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'],
+ // dependencies of i18n.ts
+ 'config': ['@@/js/config.js'],
},
- chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
- assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
+ entryFileNames: 'scripts/[hash:8].js',
+ chunkFileNames: 'scripts/[hash:8].js',
+ assetFileNames: 'assets/[hash:8][extname]',
paths(id) {
for (const p of externalPackages) {
if (p.match.test(id)) {