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})
This commit is contained in:
anatawa12 2025-08-08 11:26:18 +09:00 committed by GitHub
parent f86239ab2f
commit 8598f3912e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1247 additions and 203 deletions

View File

@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json',
'-e', 'ts,js,mjs,cjs,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{

View File

@ -184,9 +184,9 @@ export type Config = {
authUrl: string;
driveUrl: string;
userAgent: string;
frontendEntry: string;
frontendEntry: { file: string | null };
frontendManifestExists: boolean;
frontendEmbedEntry: string;
frontendEmbedEntry: { file: string | null };
frontendEmbedManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@ -235,10 +235,10 @@ export function loadConfig(): Config {
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' } };
: { 'src/_boot_.ts': { file: null } };
const frontendEmbedManifest = frontendEmbedManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: 'src/boot.ts' } };
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;

View File

@ -32,61 +32,30 @@
}
//#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;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !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';
}
}
// 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';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/embed_vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT');
@ -115,10 +84,26 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
const reload = locale?.reload || 'Reload';
const title = messages?.title || 'Failed to initialize Misskey';
const reload = messages?.reload || 'Reload';
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<div class="message">${title}</div>

View File

@ -22,62 +22,31 @@
return;
}
//#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);
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !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';
}
}
// 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';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
@ -162,9 +131,25 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const messages = Object.assign({
messages = Object.assign({
title: 'Failed to initialize Misskey',
solution: 'The following actions may solve the problem.',
solution1: 'Update your os and browser',
@ -176,8 +161,8 @@
otherOption2: 'Start the simple client',
otherOption3: 'Start the repair tool',
otherOption4: 'Start Misskey in safe mode',
}, locale?._bootErrors || {});
const reload = locale?.reload || 'Reload';
reload: 'Reload',
}, messages);
const safeModeUrl = new URL(window.location.href);
safeModeUrl.searchParams.set('safemode', 'true');
@ -193,7 +178,7 @@
</svg>
<h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${reload}</span>
<span class="button-label-big">${messages?.reload}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>

View File

@ -19,7 +19,6 @@ html(class='embed')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
if !config.frontendEmbedManifestExists
script(type="module" src="/embed_vite/@vite/client")
@ -40,7 +39,7 @@ html(class='embed')
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

@ -37,7 +37,6 @@ html
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
link(rel='modulepreload' href=`/vite/${entry.file}`)
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
@ -69,7 +68,7 @@ html
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

@ -0,0 +1,3 @@
This package contains the common scripts that are used to build the frontend and frontend-embed packages.

View File

@ -0,0 +1,98 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import pluginMisskey from '@misskey-dev/eslint-plugin';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
files: ['**/*.vue'],
...pluginMisskey.configs.typescript,
},
{
files: [
'@types/**/*.ts',
'js/**/*.ts',
'**/*.vue',
],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
...globals.browser,
// Node.js
module: false,
require: false,
__dirname: false,
// Misskey
_DEV_: false,
_LANGS_: false,
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
},
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
alphabetical: false,
}],
'vue/no-use-v-if-with-v-for': ['error', {
allowUsingIterationVar: false,
}],
'vue/no-ref-as-operand': 'error',
'vue/no-multi-spaces': ['error', {
ignoreProperties: false,
}],
'vue/no-v-html': 'warn',
'vue/order-in-components': 'error',
'vue/html-indent': ['warn', 'tab', {
attribute: 1,
baseIndent: 0,
closeBracket: 0,
alignAttributesVertically: true,
ignores: [],
}],
'vue/html-closing-bracket-spacing': ['warn', {
startTag: 'never',
endTag: 'never',
selfClosingTag: 'never',
}],
'vue/multi-word-component-names': 'warn',
'vue/require-v-for-key': 'warn',
'vue/no-unused-components': 'warn',
'vue/no-unused-vars': 'warn',
'vue/no-dupe-keys': 'warn',
'vue/valid-v-for': 'warn',
'vue/return-in-computed-property': 'warn',
'vue/no-setup-props-reactivity-loss': 'warn',
'vue/max-attributes-per-line': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/v-on-event-hyphenation': ['error', 'never', {
autofix: true,
}],
'vue/attribute-hyphenation': ['error', 'never'],
},
},
{
ignores: [
],
},
];

View File

@ -0,0 +1,145 @@
import * as fs from 'fs/promises';
import * as path from 'node:path';
import { type Locale } from '../../locales/index.js';
import type { Manifest as ViteManifest } from 'vite';
import MagicString from 'magic-string';
import { collectModifications } from './locale-inliner/collect-modifications.js';
import { applyWithLocale } from './locale-inliner/apply-with-locale.js';
import { blankLogger, type Logger } from './logger.js';
export class LocaleInliner {
outputDir: string;
scriptsDir: string;
i18nFile: string;
i18nFileName: string;
logger: Logger;
chunks: ScriptChunk[];
static async create(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
logger: Logger,
}): Promise<LocaleInliner> {
const manifest: ViteManifest = JSON.parse(await fs.readFile(`${options.outputDir}/manifest.json`, 'utf-8'));
return new LocaleInliner({ ...options, manifest });
}
constructor(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
manifest: ViteManifest,
logger: Logger,
}) {
this.outputDir = options.outputDir;
this.scriptsDir = options.scriptsDir;
this.i18nFile = options.i18nFile;
this.i18nFileName = this.stripScriptDir(options.manifest[this.i18nFile].file);
this.logger = options.logger;
this.chunks = Object.values(options.manifest).filter(chunk => this.isScriptFile(chunk.file)).map(chunk => ({
fileName: this.stripScriptDir(chunk.file),
chunkName: chunk.name,
}));
}
async loadFiles() {
await Promise.all(this.chunks.map(async chunk => {
const filePath = path.join(this.outputDir, this.scriptsDir, chunk.fileName);
chunk.sourceCode = await fs.readFile(filePath, 'utf-8');
}));
}
collectsModifications() {
for (const chunk of this.chunks) {
if (!chunk.sourceCode) {
throw new Error(`Source code for ${chunk.fileName} is not loaded.`);
}
const fileLogger = this.logger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
chunk.modifications = collectModifications(chunk.sourceCode, chunk.fileName, fileLogger, this);
}
}
async saveAllLocales(locales: Record<string, Locale>) {
const localeNames = Object.keys(locales);
for (const localeName of localeNames) {
await this.saveLocale(localeName, locales[localeName]);
}
}
async saveLocale(localeName: string, localeJson: Locale) {
// create directory
await fs.mkdir(path.join(this.outputDir, localeName), { recursive: true });
const localeLogger = localeName == 'ja-JP' ? this.logger : blankLogger; // we want to log for single locale only
for (const chunk of this.chunks) {
if (!chunk.sourceCode || !chunk.modifications) {
throw new Error(`Source code or modifications for ${chunk.fileName} is not available.`);
}
const fileLogger = localeLogger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
const magicString = new MagicString(chunk.sourceCode);
applyWithLocale(magicString, chunk.modifications, localeName, localeJson, fileLogger);
await fs.writeFile(path.join(this.outputDir, localeName, chunk.fileName), magicString.toString());
}
}
isScriptFile(fileName: string) {
return fileName.startsWith(this.scriptsDir + '/') && fileName.endsWith('.js');
}
stripScriptDir(fileName: string) {
if (!fileName.startsWith(this.scriptsDir + '/')) {
throw new Error(`${fileName} does not start with ${this.scriptsDir}/`);
}
return fileName.slice(this.scriptsDir.length + 1);
}
}
interface ScriptChunk {
fileName: string;
chunkName?: string;
sourceCode?: string;
modifications?: TextModification[];
}
export type TextModification = {
type: 'delete';
begin: number;
end: number;
localizedOnly: boolean;
} | {
// can be used later to insert '../scripts' for common files
type: 'insert';
begin: number;
text: string;
localizedOnly: boolean;
} | {
type: 'replace';
begin: number;
end: number;
text: string;
localizedOnly: boolean;
} | {
type: 'localized';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'parameterized-function';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'locale-name';
begin: number;
end: number;
literal: boolean;
localizedOnly: true;
} | {
type: 'locale-json';
begin: number;
end: number;
localizedOnly: true;
};

View File

@ -0,0 +1,97 @@
import MagicString from 'magic-string';
import type { Locale } from '../../../locales/index.js';
import { assertNever } from '../utils.js';
import type { TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js';
export function applyWithLocale(
sourceCode: MagicString,
modifications: TextModification[],
localeName: string,
localeJson: Locale,
fileLogger: Logger,
) {
for (const modification of modifications) {
switch (modification.type) {
case "delete":
sourceCode.remove(modification.begin, modification.end);
break;
case "insert":
sourceCode.appendRight(modification.begin, modification.text);
break;
case "replace":
sourceCode.update(modification.begin, modification.end, modification.text);
break;
case "localized": {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
if (accessed == null) {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
}
sourceCode.update(modification.begin, modification.end, JSON.stringify(accessed));
break;
}
case "parameterized-function": {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
let replacement: string;
if (typeof accessed === 'string') {
replacement = formatFunction(accessed);
} else if (typeof accessed === 'object' && accessed !== null) {
replacement = `({${Object.entries(accessed).map(([key, value]) => `${JSON.stringify(key)}:${formatFunction(value)}`).join(',')}})`;
} else {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
replacement = '(() => "")'; // placeholder for missing locale
}
sourceCode.update(modification.begin, modification.end, replacement);
break;
function formatFunction(accessed: string): string {
const params = new Set<string>();
const components: string[] = [];
let lastIndex = 0;
for (const match of accessed.matchAll(/\{(.+?)}/g)) {
const [fullMatch, paramName] = match;
if (lastIndex < match.index) {
components.push(JSON.stringify(accessed.slice(lastIndex, match.index)));
}
params.add(paramName);
components.push(paramName);
lastIndex = match.index + fullMatch.length;
}
components.push(JSON.stringify(accessed.slice(lastIndex)));
// we replace with `(({name,count})=>(name+count+"some"))`
const paramList = Array.from(params).join(',');
let body = components.filter(x => x != '""').join('+');
if (body == '') body = '""'; // if the body is empty, we return empty string
return `(({${paramList}})=>(${body}))`;
}
}
case "locale-name": {
sourceCode.update(modification.begin, modification.end, modification.literal ? JSON.stringify(localeName) : localeName);
break;
}
case "locale-json": {
// locale-json is inlined to place where initialize module-level variable which is executed only once.
// In such case we can use JSON.parse to speed up the parsing script.
// https://v8.dev/blog/cost-of-javascript-2019#json
sourceCode.update(modification.begin, modification.end, `JSON.parse(${JSON.stringify(JSON.stringify(localeJson))})`);
break;
}
default: {
assertNever(modification);
}
}
}
}
function getPropertyByPath(localeJson: any, localizationKey: string[]): string | object | null {
if (localizationKey.length === 0) return localeJson;
let current: any = localeJson;
for (const key of localizationKey) {
if (typeof current !== 'object' || current === null || !(key in current)) {
return null; // Key not found
}
current = current[key];
}
return current ?? null;
}

View File

@ -0,0 +1,419 @@
import type { AstNode, ProgramNode } from 'rollup';
import { parseAst } from 'vite';
import * as estreeWalker from 'estree-walker';
import type * as estree from 'estree';
import type { LocaleInliner, TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js'
import { assertNever, assertType } from '../utils.js';
// WalkerContext is not exported from estree-walker, so we define it here
interface WalkerContext {
skip: () => void;
}
export function collectModifications(sourceCode: string, fileName: string, fileLogger: Logger, inliner: LocaleInliner): TextModification[] {
let programNode: ProgramNode;
try {
programNode = parseAst(sourceCode);
} catch (e) {
fileLogger.error(`Failed to parse source code: ${e}`);
return [];
}
if (programNode.sourceType !== 'module') {
fileLogger.error(`Source code is not a module.`);
return [];
}
const modifications: TextModification[] = [];
// first
// 1) replace all `scripts/` path literals with locale code
// 2) replace all `localStorage.getItem("lang")` with `localeName` variable
// 3) replace all `await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json())` with `localeJson` variable
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node: Node) {
assertType<AstNode>(node)
if (node.type === 'Literal' && typeof node.value === 'string' && node.raw) {
if (node.raw.substring(1).startsWith(inliner.scriptsDir)) {
// we find `scripts/\w+\.js` literal and replace 'scripts' part with locale code
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.scriptsDir}/ path literal ${node.raw}`);
modifications.push({
type: 'locale-name',
begin: node.start + 1,
end: node.start + 1 + inliner.scriptsDir.length,
literal: false,
localizedOnly: true,
});
}
if (node.raw.substring(1, node.raw.length - 1) == `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
// we find `scripts/i18n.ts` literal.
// This is tipically in depmap and replace with this file name to avoid unnecessary loading i18n script
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.i18nFileName} path literal ${node.raw}`);
modifications.push({
type: 'replace',
begin: node.end - 1 - inliner.i18nFileName.length,
end: node.end - 1,
text: fileName,
localizedOnly: true,
});
}
}
if (isLocalStorageGetItemLang(node)) {
fileLogger.debug(`${lineCol(sourceCode, node)}: found localStorage.getItem("lang") call`);
modifications.push({
type: 'locale-name',
begin: node.start,
end: node.end,
literal: true,
localizedOnly: true,
});
}
if (isAwaitFetchLocaleThenJson(node)) {
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json(), () => null)
fileLogger.debug(`${lineCol(sourceCode, node)}: found await window.fetch(\`/assets/locales/\${d}.\${x}.json\`).then(u=>u.json()) call`);
modifications.push({
type: 'locale-json',
begin: node.start,
end: node.end,
localizedOnly: true,
});
}
}
})
const importSpecifierResult = findImportSpecifier(programNode, inliner.i18nFileName, 'i18n');
switch (importSpecifierResult.type) {
case 'no-import':
fileLogger.debug(`No import of i18n found, skipping inlining.`);
return modifications;
case 'no-specifiers':
fileLogger.debug(`Importing i18n without specifiers, removing the import.`);
modifications.push({
type: 'delete',
begin: importSpecifierResult.importNode.start,
end: importSpecifierResult.importNode.end,
localizedOnly: false,
});
return modifications;
case 'unexpected-specifiers':
fileLogger.info(`Importing ${inliner.i18nFileName} found but with unexpected specifiers. Skipping inlining.`);
return modifications;
case 'specifier':
fileLogger.debug(`Found import i18n as ${importSpecifierResult.localI18nIdentifier}`);
break;
}
const i18nImport = importSpecifierResult.importNode;
const localI18nIdentifier = importSpecifierResult.localI18nIdentifier;
// Check if the identifier is already declared in the file.
// If it is, we may overwrite it and cause issues so we skip inlining
let isSupported = true;
estreeWalker.walk(programNode, {
enter(node) {
if (node.type == 'VariableDeclaration') {
assertType<estree.VariableDeclaration>(node);
for (let id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
if (id == localI18nIdentifier) {
isSupported = false;
}
}
}
}
})
if (!isSupported) {
fileLogger.error(`Duplicated identifier "${localI18nIdentifier}" in variable declaration. Skipping inlining.`);
return modifications;
}
fileLogger.debug(`imports i18n as ${localI18nIdentifier}`);
// In case of substitution failure, we will preserve the import statement
// otherwise we will remove it.
let preserveI18nImport = false;
const toSkip = new Set();
toSkip.add(i18nImport);
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node, parent, property) {
assertType<AstNode>(node)
assertType<AstNode>(parent)
if (toSkip.has(node)) {
// This is the import specifier, skip processing it
this.skip();
return;
}
// We don't care original name part of the import declaration
if (node.type == 'ImportDeclaration') this.skip();
if (node.type === 'Identifier') {
assertType<estree.Identifier>(node)
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent)
if (parent.type === 'Property' && !parent.computed && property == 'key') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'MemberExpression' && !parent.computed && property == 'property') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'ExportSpecifier' && property == 'exported') return; // we don't care 'id' part of { id: expr }
if (node.name == localI18nIdentifier) {
fileLogger.error(`${lineCol(sourceCode, node)}: Using i18n identifier "${localI18nIdentifier}" directly. Skipping inlining.`);
preserveI18nImport = true;
}
} else if (node.type === 'MemberExpression') {
assertType<estree.MemberExpression>(node);
const i18nPath = parseI18nPropertyAccess(node);
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] == 'ts') {
if (parent.type === 'CallExpression' && property == 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
if (i18nPath.at(-1)?.startsWith('_')) fileLogger.debug(`found i18n grouped property access ${i18nPath.join('.')}`);
else fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n property access ${i18nPath.join('.')}`);
// it's i18n.ts.propertyAccess
// i18n.ts.* will always be resolved to string or object containing strings
modifications.push({
type: 'localized',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'ts' prefix
localizedOnly: true,
});
this.skip();
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] == 'tsx') {
// it's parameterized locale substitution (`i18n.tsx.property(parameters)`)
// we expect the parameter to be an object literal
fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n function access (object) ${i18nPath.join('.')}`);
modifications.push({
type: 'parameterized-function',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'tsx' prefix
localizedOnly: true,
});
this.skip();
}
} else if (node.type === 'ArrowFunctionExpression') {
assertType<estree.ArrowFunctionExpression>(node);
// If there is 'i18n' in the parameters, we care interior of the function
if (node.params.flatMap(param => declsOfPattern(param)).includes(localI18nIdentifier)) this.skip();
}
}
})
if (!preserveI18nImport) {
fileLogger.debug(`removing i18n import statement`);
modifications.push({
type: 'delete',
begin: i18nImport.start,
end: i18nImport.end,
localizedOnly: true,
});
}
function parseI18nPropertyAccess(node: estree.Expression | estree.Super): string[] | null {
if (node.type === 'Identifier' && node.name == localI18nIdentifier) return []; // i18n itself
if (node.type !== 'MemberExpression') return null;
// super.*
if (node.object.type === 'Super') return null;
// i18n?.property is not supported
if (node.optional) return null;
let id: string | null = null;
if (node.computed) {
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
id = node.property.value;
}
} else {
if (node.property.type === 'Identifier') {
id = node.property.name;
}
}
// non-constant property access
if (id == null) return null;
const parentAccess = parseI18nPropertyAccess(node.object);
if (parentAccess == null) return null;
return [...parentAccess, id];
}
return modifications;
}
function declsOfPattern(pattern: estree.Pattern | null): string[] {
if (pattern == null) return [];
switch (pattern?.type) {
case "Identifier":
return [pattern.name];
case "ObjectPattern":
return pattern.properties.flatMap(prop => {
switch (prop.type) {
case 'Property':
return declsOfPattern(prop.value);
case 'RestElement':
return declsOfPattern(prop.argument);
default:
assertNever(prop)
}
});
case "ArrayPattern":
return pattern.elements.flatMap(p => declsOfPattern(p));
case "RestElement":
return declsOfPattern(pattern.argument);
case "AssignmentPattern":
return declsOfPattern(pattern.left);
case "MemberExpression":
// assignment pattern so no new variable is declared
return [];
default:
assertNever(pattern);
}
}
function lineCol(sourceCode: string, node: estree.Node): string {
assertType<AstNode>(node);
const leading = sourceCode.slice(0, node.start);
const lines = leading.split('\n');
const line = lines.length;
const col = lines[lines.length - 1].length + 1; // +1 for 1-based index
return `(${line}:${col})`;
}
//region checker functions
type Node =
| estree.AssignmentProperty
| estree.CatchClause
| estree.Class
| estree.ClassBody
| estree.Expression
| estree.Function
| estree.Identifier
| estree.Literal
| estree.MethodDefinition
| estree.ModuleDeclaration
| estree.ModuleSpecifier
| estree.Pattern
| estree.PrivateIdentifier
| estree.Program
| estree.Property
| estree.PropertyDefinition
| estree.SpreadElement
| estree.Statement
| estree.Super
| estree.SwitchCase
| estree.TemplateElement
| estree.VariableDeclarator
;
// localStorage.getItem("lang")
function isLocalStorageGetItemLang(getItemCall: Node): boolean {
if (getItemCall.type !== 'CallExpression') return false;
if (getItemCall.arguments.length !== 1) return false;
const langLiteral = getItemCall.arguments[0];
if (!isStringLiteral(langLiteral, 'lang')) return false;
const getItemFunction = getItemCall.callee;
if (!isMemberExpression(getItemFunction, 'getItem')) return false;
const localStorageObject = getItemFunction.object;
if (!isIdentifier(localStorageObject, 'localStorage')) return false;
return true;
}
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u => u.json(), ....)
function isAwaitFetchLocaleThenJson(awaitNode: Node): boolean {
if (awaitNode.type !== 'AwaitExpression') return false;
const thenCall = awaitNode.argument;
if (thenCall.type !== 'CallExpression') return false;
if (thenCall.arguments.length < 1) return false;
const arrowFunction = thenCall.arguments[0];
if (arrowFunction.type !== 'ArrowFunctionExpression') return false;
if (arrowFunction.params.length !== 1) return false;
const arrowBodyCall = arrowFunction.body;
if (arrowBodyCall.type !== 'CallExpression') return false;
const jsonFunction = arrowBodyCall.callee;
if (!isMemberExpression(jsonFunction, 'json')) return false;
const thenFunction = thenCall.callee;
if (!isMemberExpression(thenFunction, 'then')) return false;
const fetchCall = thenFunction.object;
if (fetchCall.type !== 'CallExpression') return false;
if (fetchCall.arguments.length !== 1) return false;
// `/assets/locales/${d}.${x}.json`
const assetLocaleTemplate = fetchCall.arguments[0];
if (assetLocaleTemplate.type !== 'TemplateLiteral') return false;
if (assetLocaleTemplate.quasis.length !== 3) return false;
if (assetLocaleTemplate.expressions.length !== 2) return false;
if (assetLocaleTemplate.quasis[0].value.cooked !== '/assets/locales/') return false;
if (assetLocaleTemplate.quasis[1].value.cooked !== '.') return false;
if (assetLocaleTemplate.quasis[2].value.cooked !== '.json') return false;
const fetchFunction = fetchCall.callee;
if (!isMemberExpression(fetchFunction, 'fetch')) return false;
const windowObject = fetchFunction.object;
if (!isIdentifier(windowObject, 'window')) return false;
return true;
}
type SpecifierResult =
| { type: 'no-import' }
| { type: 'no-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'unexpected-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'specifier', localI18nIdentifier: string, importNode: estree.ImportDeclaration & AstNode }
;
function findImportSpecifier(programNode: ProgramNode, i18nFileName: string, i18nSymbol: string): SpecifierResult {
const imports = programNode.body.filter(x => x.type === 'ImportDeclaration');
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration;
if (!importNode) return { type: 'no-import' };
assertType<AstNode>(importNode);
if (importNode.specifiers.length == 0) {
return { type: 'no-specifiers', importNode };
}
if (importNode.specifiers.length != 1) {
return { type: 'unexpected-specifiers', importNode };
}
const i18nImportSpecifier = importNode.specifiers[0];
if (i18nImportSpecifier.type !== 'ImportSpecifier') {
return { type: 'unexpected-specifiers', importNode };
}
if (i18nImportSpecifier.imported.type !== 'Identifier') {
return { type: 'unexpected-specifiers', importNode };
}
const importingIdentifier = i18nImportSpecifier.imported.name;
if (importingIdentifier !== i18nSymbol) {
return { type: 'unexpected-specifiers', importNode };
}
const localI18nIdentifier = i18nImportSpecifier.local.name;
return { type: 'specifier', localI18nIdentifier, importNode };
}
// checker helpers
function isMemberExpression(node: Node, property: string): node is estree.MemberExpression {
return node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier' && node.property.name === property;
}
function isStringLiteral(node: Node, value: string): node is estree.Literal {
return node.type === 'Literal' && typeof node.value === 'string' && node.value === value;
}
function isIdentifier(node: Node, name: string): node is estree.Identifier {
return node.type === 'Identifier' && node.name === name;
}
//endregion

View File

@ -0,0 +1,66 @@
const debug = false;
export interface Logger {
debug(message: string): void;
warn(message: string): void;
error(message: string): void;
info(message: string): void;
prefixed(newPrefix: string): Logger;
}
interface RootLogger extends Logger {
warningCount: number;
errorCount: number;
}
export function createLogger(): RootLogger {
return loggerFactory('', {
warningCount: 0,
errorCount: 0,
});
}
type LogContext = {
warningCount: number;
errorCount: number;
}
function loggerFactory(prefix: string, context: LogContext): RootLogger {
return {
debug: (message: string) => {
if (debug) console.log(`[DBG] ${prefix}${message}`);
},
warn: (message: string) => {
context.warningCount++;
console.log(`${debug ? '[WRN]' : 'w:'} ${prefix}${message}`);
},
error: (message: string) => {
context.errorCount++;
console.error(`${debug ? '[ERR]' : 'e:'} ${prefix}${message}`);
},
info: (message: string) => {
console.error(`${debug ? '[INF]' : 'i:'} ${prefix}${message}`);
},
prefixed: (newPrefix: string) => {
return loggerFactory(`${prefix}${newPrefix}`, context);
},
get warningCount() {
return context.warningCount;
},
get errorCount() {
return context.errorCount;
},
};
}
export const blankLogger: Logger = {
debug: () => void 0,
warn: () => void 0,
error: () => void 0,
info: () => void 0,
prefixed: () => blankLogger,
}

View File

@ -0,0 +1,25 @@
{
"name": "frontend-builder",
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"exports": {
"./*": "./js/*"
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "22.17.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"rollup": "4.46.2",
"typescript": "5.9.2"
},
"dependencies": {
"estree-walker": "3.0.3",
"magic-string": "0.30.17",
"vite": "7.0.6"
}
}

View File

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as estreeWalker from 'estree-walker';
import type { Plugin } from 'vite';
import type { CallExpression, Expression, Program, } from 'estree';
import MagicString from 'magic-string';
import type { AstNode } from 'rollup';
import { assertType } from './utils.js';
// This plugin transforms `unref(i18n)` to `i18n` in the code, which is useful for removing unnecessary unref calls
// and helps locale inliner runs after vite build to inline the locale data into the final build.
//
// locale inliner cannot know minifiedSymbol(i18n) is 'unref(i18n)' or 'otherFunctionsWithEffect(i18n)' so
// it is necessary to remove unref calls before minification.
export default function pluginRemoveUnrefI18n(
{
i18nSymbolName = 'i18n',
}: {
i18nSymbolName?: string
} = {}): Plugin {
return {
name: 'UnwindCssModuleClassName',
renderChunk(code) {
if (!code.includes('unref(i18n)')) return null;
const ast = this.parse(code) as Program;
const magicString = new MagicString(code);
estreeWalker.walk(ast, {
enter(node) {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'unref'
&& node.arguments.length === 1) {
// calls to unref with single argument
const arg = node.arguments[0];
if (arg.type === 'Identifier' && arg.name === i18nSymbolName) {
// this is unref(i18n) so replace it with i18n
// to replace, remove the 'unref(' and the trailing ')'
assertType<CallExpression & AstNode>(node);
assertType<Expression & AstNode>(arg);
magicString.remove(node.start, arg.start);
magicString.remove(arg.end, node.end);
}
}
}
});
return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true }),
}
},
};
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"typeRoots": [
"./@types",
"./node_modules/@types"
],
"lib": [
"esnext"
]
}
}

View File

@ -0,0 +1,7 @@
export function assertNever(x: never): never {
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
}
export function assertType<T>(node: unknown): asserts node is T {
}

View File

@ -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_embed_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();

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
@ -20,8 +20,8 @@
"astring": "1.9.0",
"buraha": "0.0.1",
"estree-walker": "3.0.3",
"icons-subsetter": "workspace:*",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"json5": "2.2.3",
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
@ -63,6 +63,7 @@
"nodemon": "3.1.10",
"prettier": "3.6.2",
"start-server-and-test": "2.0.12",
"tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "3.0.5",
"vue-eslint-parser": "10.2.0",

View File

@ -17,15 +17,16 @@ 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 { storeBootloaderErrors } from '@@/js/store-boot-errors';
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 { url, version, lang } 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 { i18n } from '@/i18n.js';
import type { Theme } from '@/theme.js';
@ -76,19 +77,7 @@ 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);
}
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
//#endregion
// サイズの制限

View File

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

View File

@ -8,6 +8,7 @@ import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' };
import pluginJson5 from './vite.json5.js';
import pluginRemoveUnrefI18n from '../frontend-builder/rollup-plugin-remove-unref-i18n';
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;
@ -85,6 +86,7 @@ export function getConfig(): UserConfig {
plugins: [
pluginVue(),
pluginRemoveUnrefI18n(),
pluginJson5(),
],
@ -135,15 +137,20 @@ 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'],
// 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)) {

View File

@ -3,8 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
@ -17,14 +15,8 @@ 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 const version = _VERSION_;
export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName;
export const ui = localStorage.getItem('ui');
export const debug = localStorage.getItem('debug') === 'true';
export const isSafeMode = localStorage.getItem('isSafeMode') === 'true';
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -39,11 +39,7 @@ export class I18n<T extends ILocale> {
private devMode: boolean;
constructor(public locale: T, devMode = false) {
// 場合によってはバージョンアップ前の翻訳データを参照した結果存在しないプロパティにアクセスしてクライアントが起動できなくなることがある問題の応急処置として非devモードでもプロキシする
// TODO: https://github.com/misskey-dev/misskey/issues/14453 が実装されたらそのようなことは発生し得なくなるため消す
const oukyuusyoti = true;
this.devMode = devMode || oukyuusyoti;
this.devMode = devMode;
//#region BIND
this.t = this.t.bind(this);

View File

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { lang, version } from '@@/js/config.js';
import type { Locale } from '../../../locales/index.js';
// ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える
export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null);
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] };
export function storeBootloaderErrors(locale: BootLoaderLocaleBody) {
localStorage.setItem('bootloaderLocales', JSON.stringify(locale));
}

View File

@ -1 +1,2 @@
/storybook-static
/build/

View File

@ -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({

View File

@ -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();

View File

@ -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",

View File

@ -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();
}
});

View File

@ -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>

View File

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

View File

@ -22,8 +22,7 @@ export type Keys = (
'fontSize' |
'ui' |
'ui_temp' |
'locale' |
'localeVersion' |
'bootloaderLocales' |
'theme' |
'themeId' |
'customCss' |

View File

@ -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, () => {

View File

@ -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');

View File

@ -46,6 +46,7 @@
},
"compileOnSave": false,
"include": [
"./build.ts",
"./lib/**/*.ts",
"./src/**/*.ts",
"./src/**/*.vue",

View File

@ -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)) {

View File

@ -796,6 +796,9 @@ importers:
eventemitter3:
specifier: 5.0.1
version: 5.0.1
execa:
specifier: 9.6.0
version: 9.6.0
frontend-shared:
specifier: workspace:*
version: link:../frontend-shared
@ -1061,6 +1064,9 @@ importers:
storybook-addon-misskey-theme:
specifier: github:misskey-dev/storybook-addon-misskey-theme
version: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640(1169897c5e200f76aeb2e7696f1450e0)
tsx:
specifier: 4.20.3
version: 4.20.3
vite-plugin-turbosnap:
specifier: 1.0.3
version: 1.0.3
@ -1080,6 +1086,37 @@ importers:
specifier: 3.0.5
version: 3.0.5(typescript@5.9.2)
packages/frontend-builder:
dependencies:
estree-walker:
specifier: 3.0.3
version: 3.0.3
magic-string:
specifier: 0.30.17
version: 0.30.17
vite:
specifier: 7.0.6
version: 7.0.6(@types/node@22.17.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3)
devDependencies:
'@types/estree':
specifier: 1.0.8
version: 1.0.8
'@types/node':
specifier: 22.17.0
version: 22.17.0
'@typescript-eslint/eslint-plugin':
specifier: 8.38.0
version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.9.2))(eslint@9.31.0)(typescript@5.9.2)
'@typescript-eslint/parser':
specifier: 8.38.0
version: 8.38.0(eslint@9.31.0)(typescript@5.9.2)
rollup:
specifier: 4.46.2
version: 4.46.2
typescript:
specifier: 5.9.2
version: 5.9.2
packages/frontend-embed:
dependencies:
'@discordapp/twemoji':
@ -1236,6 +1273,9 @@ importers:
start-server-and-test:
specifier: 2.0.12
version: 2.0.12
tsx:
specifier: 4.20.3
version: 4.20.3
vite-plugin-turbosnap:
specifier: 1.0.3
version: 1.0.3
@ -6771,14 +6811,6 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.4.4:
resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fdir@6.4.6:
resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==}
peerDependencies:
@ -15365,7 +15397,7 @@ snapshots:
'@typescript-eslint/project-service@8.34.0(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0
'@typescript-eslint/types': 8.38.0
debug: 4.4.1(supports-color@10.0.0)
typescript: 5.8.3
transitivePeerDependencies:
@ -15373,8 +15405,8 @@ snapshots:
'@typescript-eslint/project-service@8.37.0(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
'@typescript-eslint/types': 8.38.0
debug: 4.4.1(supports-color@10.0.0)
typescript: 5.8.3
transitivePeerDependencies:
@ -15804,7 +15836,7 @@ snapshots:
alien-signals: 2.0.6
muggle-string: 0.4.1
path-browserify: 1.0.1
picomatch: 4.0.2
picomatch: 4.0.3
optionalDependencies:
typescript: 5.9.2
@ -18072,10 +18104,6 @@ snapshots:
dependencies:
pend: 1.2.0
fdir@6.4.4(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
fdir@6.4.6(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -22364,8 +22392,8 @@ snapshots:
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
fdir: 6.4.6(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}

View File

@ -2,6 +2,7 @@ packages:
- packages/backend
- packages/frontend-shared
- packages/frontend
- packages/frontend-builder
- packages/frontend-embed
- packages/icons-subsetter
- packages/sw

View File

@ -13,6 +13,8 @@ const fs = require('fs');
fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend-builder/node_modules', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true });
fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true });