summaryrefslogtreecommitdiff
path: root/packages/frontend-builder
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-builder
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-builder')
-rw-r--r--packages/frontend-builder/README.txt3
-rw-r--r--packages/frontend-builder/eslint.config.js98
-rw-r--r--packages/frontend-builder/locale-inliner.ts145
-rw-r--r--packages/frontend-builder/locale-inliner/apply-with-locale.ts97
-rw-r--r--packages/frontend-builder/locale-inliner/collect-modifications.ts419
-rw-r--r--packages/frontend-builder/logger.ts66
-rw-r--r--packages/frontend-builder/package.json25
-rw-r--r--packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts53
-rw-r--r--packages/frontend-builder/tsconfig.json29
-rw-r--r--packages/frontend-builder/utils.ts7
10 files changed, 942 insertions, 0 deletions
diff --git a/packages/frontend-builder/README.txt b/packages/frontend-builder/README.txt
new file mode 100644
index 0000000000..db878ffa83
--- /dev/null
+++ b/packages/frontend-builder/README.txt
@@ -0,0 +1,3 @@
+This package contains the common scripts that are used to build the frontend and frontend-embed packages.
+
+
diff --git a/packages/frontend-builder/eslint.config.js b/packages/frontend-builder/eslint.config.js
new file mode 100644
index 0000000000..5805c9924d
--- /dev/null
+++ b/packages/frontend-builder/eslint.config.js
@@ -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: [
+ ],
+ },
+];
diff --git a/packages/frontend-builder/locale-inliner.ts b/packages/frontend-builder/locale-inliner.ts
new file mode 100644
index 0000000000..ce3f59a81c
--- /dev/null
+++ b/packages/frontend-builder/locale-inliner.ts
@@ -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;
+};
diff --git a/packages/frontend-builder/locale-inliner/apply-with-locale.ts b/packages/frontend-builder/locale-inliner/apply-with-locale.ts
new file mode 100644
index 0000000000..a79ebb4253
--- /dev/null
+++ b/packages/frontend-builder/locale-inliner/apply-with-locale.ts
@@ -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;
+}
diff --git a/packages/frontend-builder/locale-inliner/collect-modifications.ts b/packages/frontend-builder/locale-inliner/collect-modifications.ts
new file mode 100644
index 0000000000..0cbf6a504e
--- /dev/null
+++ b/packages/frontend-builder/locale-inliner/collect-modifications.ts
@@ -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
diff --git a/packages/frontend-builder/logger.ts b/packages/frontend-builder/logger.ts
new file mode 100644
index 0000000000..a3f66730e2
--- /dev/null
+++ b/packages/frontend-builder/logger.ts
@@ -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,
+}
diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json
new file mode 100644
index 0000000000..5fdd25b32d
--- /dev/null
+++ b/packages/frontend-builder/package.json
@@ -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"
+ }
+}
diff --git a/packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts b/packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
new file mode 100644
index 0000000000..9010e6910c
--- /dev/null
+++ b/packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
@@ -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 }),
+ }
+ },
+ };
+}
diff --git a/packages/frontend-builder/tsconfig.json b/packages/frontend-builder/tsconfig.json
new file mode 100644
index 0000000000..9250b2f3da
--- /dev/null
+++ b/packages/frontend-builder/tsconfig.json
@@ -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"
+ ]
+ }
+}
diff --git a/packages/frontend-builder/utils.ts b/packages/frontend-builder/utils.ts
new file mode 100644
index 0000000000..1ce9f7fc76
--- /dev/null
+++ b/packages/frontend-builder/utils.ts
@@ -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 {
+}