forked from mirrors/misskey
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:
3
packages/frontend-builder/README.txt
Normal file
3
packages/frontend-builder/README.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
This package contains the common scripts that are used to build the frontend and frontend-embed packages.
|
||||
|
||||
|
||||
98
packages/frontend-builder/eslint.config.js
Normal file
98
packages/frontend-builder/eslint.config.js
Normal 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: [
|
||||
],
|
||||
},
|
||||
];
|
||||
145
packages/frontend-builder/locale-inliner.ts
Normal file
145
packages/frontend-builder/locale-inliner.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
66
packages/frontend-builder/logger.ts
Normal file
66
packages/frontend-builder/logger.ts
Normal 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,
|
||||
}
|
||||
25
packages/frontend-builder/package.json
Normal file
25
packages/frontend-builder/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
53
packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
Normal file
53
packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts
Normal 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 }),
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
29
packages/frontend-builder/tsconfig.json
Normal file
29
packages/frontend-builder/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
packages/frontend-builder/utils.ts
Normal file
7
packages/frontend-builder/utils.ts
Normal 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 {
|
||||
}
|
||||
Reference in New Issue
Block a user