diff options
| author | anatawa12 <anatawa12@icloud.com> | 2025-08-08 11:26:18 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-08 11:26:18 +0900 |
| commit | 8598f3912ecc16f9b7c3f502e09d9ea96f7e507d (patch) | |
| tree | 73b24c7ab473189a679ae6aa476b6822f812cbed /packages/frontend-builder | |
| parent | Update CONTRIBUTING.md (diff) | |
| download | misskey-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.txt | 3 | ||||
| -rw-r--r-- | packages/frontend-builder/eslint.config.js | 98 | ||||
| -rw-r--r-- | packages/frontend-builder/locale-inliner.ts | 145 | ||||
| -rw-r--r-- | packages/frontend-builder/locale-inliner/apply-with-locale.ts | 97 | ||||
| -rw-r--r-- | packages/frontend-builder/locale-inliner/collect-modifications.ts | 419 | ||||
| -rw-r--r-- | packages/frontend-builder/logger.ts | 66 | ||||
| -rw-r--r-- | packages/frontend-builder/package.json | 25 | ||||
| -rw-r--r-- | packages/frontend-builder/rollup-plugin-remove-unref-i18n.ts | 53 | ||||
| -rw-r--r-- | packages/frontend-builder/tsconfig.json | 29 | ||||
| -rw-r--r-- | packages/frontend-builder/utils.ts | 7 |
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 { +} |