diff --git a/packages/backend/scripts/dev.mjs b/packages/backend/scripts/dev.mjs index a3e0558abd..023eb7eae6 100644 --- a/packages/backend/scripts/dev.mjs +++ b/packages/backend/scripts/dev.mjs @@ -42,7 +42,7 @@ async function killProc() { './node_modules/nodemon/bin/nodemon.js', [ '-w', 'src', - '-e', 'ts,js,mjs,cjs,json', + '-e', 'ts,js,mjs,cjs,json,pug', '--exec', 'pnpm', 'run', 'build', ], { diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 9031096745..f71f1d7e34 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -184,9 +184,9 @@ export type Config = { authUrl: string; driveUrl: string; userAgent: string; - frontendEntry: string; + frontendEntry: { file: string | null }; frontendManifestExists: boolean; - frontendEmbedEntry: string; + frontendEmbedEntry: { file: string | null }; frontendEmbedManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; @@ -235,10 +235,10 @@ export function loadConfig(): Config { const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); const frontendManifest = frontendManifestExists ? JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) - : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; + : { 'src/_boot_.ts': { file: null } }; const frontendEmbedManifest = frontendEmbedManifestExists ? JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) - : { 'src/boot.ts': { file: 'src/boot.ts' } }; + : { 'src/boot.ts': { file: null } }; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index 9de1275380..022ff064ad 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -32,61 +32,30 @@ } //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { - const supportedLangs = LANGS; - let lang = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - - // Fallback - if (lang == null) lang = 'en-US'; - } - } - - const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (metaRes.status !== 200) { - renderError('META_FETCH'); - return; - } - const meta = await metaRes.json(); - const v = meta.version; - if (v == null) { - renderError('META_FETCH_V'); - return; - } - - // for https://github.com/misskey-dev/misskey/issues/10202 - if (lang == null || lang.toString == null || lang.toString() === 'null') { - console.error('invalid lang value detected!!!', typeof lang, lang); - lang = 'en-US'; - } - - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); - if (localRes.status === 200) { - localStorage.setItem('lang', lang); - localStorage.setItem('locale', await localRes.text()); - localStorage.setItem('localeVersion', v); + const supportedLangs = LANGS; + /** @type { string } */ + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; } else { - renderError('LOCALE_FETCH'); - return; + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; } } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } //#endregion //#region Script async function importAppScript() { - await import(`/embed_vite/${CLIENT_ENTRY}`) + await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts') .catch(async e => { console.error(e); renderError('APP_IMPORT'); @@ -115,10 +84,26 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } - const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + let messages = null; + const bootloaderLocales = localStorage.getItem('bootloaderLocales'); + if (bootloaderLocales) { + messages = JSON.parse(bootloaderLocales); + } + if (!messages) { + // older version of misskey does not store bootloaderLocales, stores locale as a whole + const legacyLocale = localStorage.getItem('locale'); + if (legacyLocale) { + const parsed = JSON.parse(legacyLocale); + messages = { + ...(parsed._bootErrors ?? {}), + reload: parsed.reload, + }; + } + } + if (!messages) messages = {}; - const title = locale?._bootErrors?.title || 'Failed to initialize Misskey'; - const reload = locale?.reload || 'Reload'; + const title = messages?.title || 'Failed to initialize Misskey'; + const reload = messages?.reload || 'Reload'; document.body.innerHTML = `
${title}
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 1a30e9ed2b..0c0b46f82b 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -22,62 +22,31 @@ return; } - //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { - const supportedLangs = LANGS; - let lang = localStorage.getItem('lang'); - if (lang == null || !supportedLangs.includes(lang)) { - if (supportedLangs.includes(navigator.language)) { - lang = navigator.language; - } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); - - // Fallback - if (lang == null) lang = 'en-US'; - } - } - - const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }); - if (metaRes.status !== 200) { - renderError('META_FETCH'); - return; - } - const meta = await metaRes.json(); - const v = meta.version; - if (v == null) { - renderError('META_FETCH_V'); - return; - } - - // for https://github.com/misskey-dev/misskey/issues/10202 - if (lang == null || lang.toString == null || lang.toString() === 'null') { - console.error('invalid lang value detected!!!', typeof lang, lang); - lang = 'en-US'; - } - - const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); - if (localRes.status === 200) { - localStorage.setItem('lang', lang); - localStorage.setItem('locale', await localRes.text()); - localStorage.setItem('localeVersion', v); + //#region Detect language + const supportedLangs = LANGS; + /** @type { string } */ + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; } else { - renderError('LOCALE_FETCH'); - return; + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; } } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } //#endregion //#region Script async function importAppScript() { - await import(`/vite/${CLIENT_ENTRY}`) + await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts') .catch(async e => { console.error(e); renderError('APP_IMPORT', e); @@ -162,9 +131,25 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } - const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + let messages = null; + const bootloaderLocales = localStorage.getItem('bootloaderLocales'); + if (bootloaderLocales) { + messages = JSON.parse(bootloaderLocales); + } + if (!messages) { + // older version of misskey does not store bootloaderLocales, stores locale as a whole + const legacyLocale = localStorage.getItem('locale'); + if (legacyLocale) { + const parsed = JSON.parse(legacyLocale); + messages = { + ...(parsed._bootErrors ?? {}), + reload: parsed.reload, + }; + } + } + if (!messages) messages = {}; - const messages = Object.assign({ + messages = Object.assign({ title: 'Failed to initialize Misskey', solution: 'The following actions may solve the problem.', solution1: 'Update your os and browser', @@ -176,8 +161,8 @@ otherOption2: 'Start the simple client', otherOption3: 'Start the repair tool', otherOption4: 'Start Misskey in safe mode', - }, locale?._bootErrors || {}); - const reload = locale?.reload || 'Reload'; + reload: 'Reload', + }, messages); const safeModeUrl = new URL(window.location.href); safeModeUrl.searchParams.set('safemode', 'true'); @@ -193,7 +178,7 @@

${messages.title}

${messages.solution}

${messages.solution1}

diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug index baa0909676..29de86b8b6 100644 --- a/packages/backend/src/server/web/views/base-embed.pug +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -19,7 +19,6 @@ html(class='embed') meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') link(rel='icon' href= icon || '/favicon.ico') link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') - link(rel='modulepreload' href=`/embed_vite/${entry.file}`) if !config.frontendEmbedManifestExists script(type="module" src="/embed_vite/@vite/client") @@ -40,7 +39,7 @@ html(class='embed') script. var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{entry.file}"; + var CLIENT_ENTRY = !{JSON.stringify(entry.file)}; script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 3883b5e5ab..a76c75fe5c 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -37,7 +37,6 @@ html link(rel='prefetch' href=serverErrorImageUrl) link(rel='prefetch' href=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) - link(rel='modulepreload' href=`/vite/${entry.file}`) if !config.frontendManifestExists script(type="module" src="/vite/@vite/client") @@ -69,7 +68,7 @@ html script. var VERSION = "#{version}"; - var CLIENT_ENTRY = "#{entry.file}"; + var CLIENT_ENTRY = !{JSON.stringify(entry.file)}; script(type='application/json' id='misskey_meta' data-generated-at=now) != metaJson 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 { + 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) { + 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(); + 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(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(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(node) + assertType(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(node) + assertType(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(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(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(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(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(node); + assertType(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(node: unknown): asserts node is T { +} diff --git a/packages/frontend-embed/build.ts b/packages/frontend-embed/build.ts new file mode 100644 index 0000000000..737233a4d0 --- /dev/null +++ b/packages/frontend-embed/build.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs/promises'; +import url from 'node:url'; +import path from 'node:path'; +import { execa } from 'execa'; +import locales from '../../locales/index.js'; +import { LocaleInliner } from '../frontend-builder/locale-inliner.js' +import { createLogger } from '../frontend-builder/logger'; + +// requires node 21 or later +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const outputDir = __dirname + '/../../built/_frontend_embed_vite_'; + +/** + * @return {Promise} + */ +async function viteBuild() { + await execa('vite', ['build'], { + cwd: __dirname, + stdout: process.stdout, + stderr: process.stderr, + }); +} + + +async function buildAllLocale() { + const logger = createLogger() + const inliner = await LocaleInliner.create({ + outputDir, + logger, + scriptsDir: 'scripts', + i18nFile: 'src/i18n.ts', + }) + + await inliner.loadFiles(); + + inliner.collectsModifications(); + + await inliner.saveAllLocales(locales); + + if (logger.errorCount > 0) { + throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`); + } +} + +async function build() { + await fs.rm(outputDir, { recursive: true, force: true }); + await viteBuild(); + await buildAllLocale(); +} + +await build(); diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index e76046add3..f9d1330ae5 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "watch": "vite", - "build": "vite build", + "build": "tsx build.ts", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" @@ -20,8 +20,8 @@ "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", - "icons-subsetter": "workspace:*", "frontend-shared": "workspace:*", + "icons-subsetter": "workspace:*", "json5": "2.2.3", "mfm-js": "0.25.0", "misskey-js": "workspace:*", @@ -63,6 +63,7 @@ "nodemon": "3.1.10", "prettier": "3.6.2", "start-server-and-test": "2.0.12", + "tsx": "4.20.3", "vite-plugin-turbosnap": "1.0.3", "vue-component-type-helpers": "3.0.5", "vue-eslint-parser": "10.2.0", diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index 459b283e23..9d69437c30 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -17,15 +17,16 @@ import { createApp, defineAsyncComponent } from 'vue'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-dark.json5'; import { MediaProxy } from '@@/js/media-proxy.js'; +import { storeBootloaderErrors } from '@@/js/store-boot-errors'; import { applyTheme, assertIsTheme } from '@/theme.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { DI } from '@/di.js'; import { serverMetadata } from '@/server-metadata.js'; -import { url, version, locale, lang, updateLocale } from '@@/js/config.js'; +import { url, version, lang } from '@@/js/config.js'; import { parseEmbedParams } from '@@/js/embed-page.js'; import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; import { serverContext } from '@/server-context.js'; -import { i18n, updateI18n } from '@/i18n.js'; +import { i18n } from '@/i18n.js'; import type { Theme } from '@/theme.js'; @@ -76,19 +77,7 @@ if (embedParams.colorMode === 'dark') { //#endregion //#region Detect language & fetch translations -const localeVersion = localStorage.getItem('localeVersion'); -const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); -if (localeOutdated) { - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - localStorage.setItem('locale', newLocale); - localStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } -} +storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload }); //#endregion // サイズの制限 diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts index 6ad503b089..0b2b206b7e 100644 --- a/packages/frontend-embed/src/i18n.ts +++ b/packages/frontend-embed/src/i18n.ts @@ -5,11 +5,12 @@ import { markRaw } from 'vue'; import { I18n } from '@@/js/i18n.js'; +import { locale } from '@@/js/locale.js'; import type { Locale } from '../../../locales/index.js'; -import { locale } from '@@/js/config.js'; export const i18n = markRaw(new I18n(locale, _DEV_)); +// test 以外では使わないこと。インライン化されてるのでだいたい意味がない export function updateI18n(newLocale: Locale) { i18n.locale = newLocale; } diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts index a057581b3a..eb57db9774 100644 --- a/packages/frontend-embed/vite.config.ts +++ b/packages/frontend-embed/vite.config.ts @@ -8,6 +8,7 @@ import locales from '../../locales/index.js'; import meta from '../../package.json'; import packageInfo from './package.json' with { type: 'json' }; import pluginJson5 from './vite.json5.js'; +import pluginRemoveUnrefI18n from '../frontend-builder/rollup-plugin-remove-unref-i18n'; const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null; const host = url ? (new URL(url)).hostname : undefined; @@ -85,6 +86,7 @@ export function getConfig(): UserConfig { plugins: [ pluginVue(), + pluginRemoveUnrefI18n(), pluginJson5(), ], @@ -135,15 +137,20 @@ export function getConfig(): UserConfig { manifest: 'manifest.json', rollupOptions: { input: { - app: './src/boot.ts', + i18n: './src/i18n.ts', + entry: './src/boot.ts', }, external: externalPackages.map(p => p.match), + preserveEntrySignatures: 'allow-extension', output: { manualChunks: { vue: ['vue'], + // dependencies of i18n.ts + 'config': ['@@/js/config.js'], }, - chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', - assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', + entryFileNames: 'scripts/[hash:8].js', + chunkFileNames: 'scripts/[hash:8].js', + assetFileNames: 'assets/[hash:8][extname]', paths(id) { for (const p of externalPackages) { if (p.match.test(id)) { diff --git a/packages/frontend-shared/js/config.ts b/packages/frontend-shared/js/config.ts index 4963d631f9..ac5c5629f3 100644 --- a/packages/frontend-shared/js/config.ts +++ b/packages/frontend-shared/js/config.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Locale } from '../../../locales/index.js'; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const address = new URL(document.querySelector('meta[property="instance_url"]')?.content || location.href); const siteName = document.querySelector('meta[property="og:site_name"]')?.content; @@ -17,14 +15,8 @@ export const apiUrl = location.origin + '/api'; export const wsOrigin = location.origin; export const lang = localStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; -const preParseLocale = localStorage.getItem('locale'); -export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null; export const version = _VERSION_; export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName; export const ui = localStorage.getItem('ui'); export const debug = localStorage.getItem('debug') === 'true'; export const isSafeMode = localStorage.getItem('isSafeMode') === 'true'; - -export function updateLocale(newLocale: Locale): void { - locale = newLocale; -} diff --git a/packages/frontend-shared/js/i18n.ts b/packages/frontend-shared/js/i18n.ts index 25581b969a..bd9ba0922a 100644 --- a/packages/frontend-shared/js/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -39,11 +39,7 @@ export class I18n { private devMode: boolean; constructor(public locale: T, devMode = false) { - // 場合によってはバージョンアップ前の翻訳データを参照した結果存在しないプロパティにアクセスしてクライアントが起動できなくなることがある問題の応急処置として非devモードでもプロキシする - // TODO: https://github.com/misskey-dev/misskey/issues/14453 が実装されたらそのようなことは発生し得なくなるため消す - const oukyuusyoti = true; - - this.devMode = devMode || oukyuusyoti; + this.devMode = devMode; //#region BIND this.t = this.t.bind(this); diff --git a/packages/frontend-shared/js/locale.ts b/packages/frontend-shared/js/locale.ts new file mode 100644 index 0000000000..87e3922fd9 --- /dev/null +++ b/packages/frontend-shared/js/locale.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { lang, version } from '@@/js/config.js'; +import type { Locale } from '../../../locales/index.js'; + +// ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える +export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null); + +export function updateLocale(newLocale: Locale): void { + locale = newLocale; +} diff --git a/packages/frontend-shared/js/store-boot-errors.ts b/packages/frontend-shared/js/store-boot-errors.ts new file mode 100644 index 0000000000..31e6248445 --- /dev/null +++ b/packages/frontend-shared/js/store-boot-errors.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Locale } from '../../../locales/index.js'; + +type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] }; + +export function storeBootloaderErrors(locale: BootLoaderLocaleBody) { + localStorage.setItem('bootloaderLocales', JSON.stringify(locale)); +} diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore index 1aa0ac14e8..4f8684c18e 100644 --- a/packages/frontend/.gitignore +++ b/packages/frontend/.gitignore @@ -1 +1,2 @@ /storybook-static +/build/ diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index fb855c1410..2aac8af400 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { userDetailed } from './fakes.js'; -import locale from './locale.js'; import { commonHandlers, onUnhandledRequest } from './mocks.js'; import themes from './themes.js'; import '../src/style.scss'; @@ -55,7 +54,6 @@ function initLocalStorage() { ...userDetailed(), policies: {}, })); - localStorage.setItem('locale', JSON.stringify(locale)); } initialize({ diff --git a/packages/frontend/build.ts b/packages/frontend/build.ts new file mode 100644 index 0000000000..0401c2b9ba --- /dev/null +++ b/packages/frontend/build.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs/promises'; +import url from 'node:url'; +import path from 'node:path'; +import { execa } from 'execa'; +import locales from '../../locales/index.js'; +import { LocaleInliner } from '../frontend-builder/locale-inliner.js' +import { createLogger } from '../frontend-builder/logger'; + +// requires node 21 or later +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const outputDir = __dirname + '/../../built/_frontend_vite_'; + +/** + * @return {Promise} + */ +async function viteBuild() { + await execa('vite', ['build'], { + cwd: __dirname, + stdout: process.stdout, + stderr: process.stderr, + }); +} + + +async function buildAllLocale() { + const logger = createLogger() + const inliner = await LocaleInliner.create({ + outputDir, + logger, + scriptsDir: 'scripts', + i18nFile: 'src/i18n.ts', + }) + + await inliner.loadFiles(); + + inliner.collectsModifications(); + + await inliner.saveAllLocales(locales); + + if (logger.errorCount > 0) { + throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`); + } +} + +async function build() { + await fs.rm(outputDir, { recursive: true, force: true }); + await viteBuild(); + await buildAllLocale(); +} + +await build(); diff --git a/packages/frontend/package.json b/packages/frontend/package.json index f4f72d944a..eaefe92612 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "watch": "vite", - "build": "vite build", + "build": "tsx build.ts", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static", @@ -47,6 +47,7 @@ "date-fns": "4.1.0", "estree-walker": "3.0.3", "eventemitter3": "5.0.1", + "execa": "9.6.0", "frontend-shared": "workspace:*", "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", @@ -137,6 +138,7 @@ "start-server-and-test": "2.0.12", "storybook": "9.1.0", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "tsx": "4.20.3", "vite-plugin-turbosnap": "1.0.3", "vitest": "3.2.4", "vitest-fetch-mock": "0.4.5", diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index ea41155ab0..395d1e5e7e 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -5,9 +5,10 @@ import { computed, watch, version as vueVersion } from 'vue'; import { compareVersions } from 'compare-versions'; -import { version, lang, updateLocale, locale, apiUrl, isSafeMode } from '@@/js/config.js'; +import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import { storeBootloaderErrors } from '@@/js/store-boot-errors'; import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; @@ -79,25 +80,7 @@ export async function common(createVue: () => Promise>) { //#endregion //#region Detect language & fetch translations - const localeVersion = miLocalStorage.getItem('localeVersion'); - const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); - - async function fetchAndUpdateLocale({ useCache } = { useCache: true }) { - const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' }; - const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions); - if (res.status === 200) { - const newLocale = await res.text(); - const parsedNewLocale = JSON.parse(newLocale); - miLocalStorage.setItem('locale', newLocale); - miLocalStorage.setItem('localeVersion', version); - updateLocale(parsedNewLocale); - updateI18n(parsedNewLocale); - } - } - - if (localeOutdated) { - fetchAndUpdateLocale(); - } + storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload }); if (import.meta.hot) { import.meta.hot.on('locale-update', async (updatedLang: string) => { @@ -106,7 +89,8 @@ export async function common(createVue: () => Promise>) { await new Promise(resolve => { window.setTimeout(resolve, 500); }); - await fetchAndUpdateLocale({ useCache: false }); + // fetch with cache: 'no-store' to ensure the latest locale is fetched + await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text()); window.location.reload(); } }); diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index a2247d844b..c9d18ee731 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }} -
+
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 6ad503b089..0b2b206b7e 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -5,11 +5,12 @@ import { markRaw } from 'vue'; import { I18n } from '@@/js/i18n.js'; +import { locale } from '@@/js/locale.js'; import type { Locale } from '../../../locales/index.js'; -import { locale } from '@@/js/config.js'; export const i18n = markRaw(new I18n(locale, _DEV_)); +// test 以外では使わないこと。インライン化されてるのでだいたい意味がない export function updateI18n(newLocale: Locale) { i18n.locale = newLocale; } diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index b64a8c5dd5..687983bcdb 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -22,8 +22,7 @@ export type Keys = ( 'fontSize' | 'ui' | 'ui_temp' | - 'locale' | - 'localeVersion' | + 'bootloaderLocales' | 'theme' | 'themeId' | 'customCss' | diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index ed4f36c0e5..fb1a409180 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -886,8 +886,6 @@ const useSystemFont = ref(miLocalStorage.getItem('useSystemFont') != null); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); }); watch(fontSize, () => { diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts index b6ae254727..8a62265438 100644 --- a/packages/frontend/src/utility/clear-cache.ts +++ b/packages/frontend/src/utility/clear-cache.ts @@ -13,8 +13,6 @@ export async function clearCache() { os.waiting(); miLocalStorage.removeItem('instance'); miLocalStorage.removeItem('instanceCachedAt'); - miLocalStorage.removeItem('locale'); - miLocalStorage.removeItem('localeVersion'); miLocalStorage.removeItem('theme'); miLocalStorage.removeItem('emojis'); miLocalStorage.removeItem('lastEmojisFetchedAt'); diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 9c7c611e5b..c445ada034 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -46,6 +46,7 @@ }, "compileOnSave": false, "include": [ + "./build.ts", "./lib/**/*.ts", "./src/**/*.ts", "./src/**/*.vue", diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 7b6bf436b1..e9707d13d1 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -14,6 +14,7 @@ import pluginJson5 from './vite.json5.js'; import pluginCreateSearchIndex from './lib/vite-plugin-create-search-index.js'; import type { Options as SearchIndexOptions } from './lib/vite-plugin-create-search-index.js'; import pluginWatchLocales from './lib/vite-plugin-watch-locales.js'; +import pluginRemoveUnrefI18n from '../frontend-builder/rollup-plugin-remove-unref-i18n.js'; const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null; const host = url ? (new URL(url)).hostname : undefined; @@ -111,6 +112,7 @@ export function getConfig(): UserConfig { pluginWatchLocales(), ...searchIndexes.map(options => pluginCreateSearchIndex(options)), pluginVue(), + pluginRemoveUnrefI18n(), pluginUnwindCssModuleClassName(), pluginJson5(), ...process.env.NODE_ENV === 'production' @@ -174,16 +176,21 @@ export function getConfig(): UserConfig { manifest: 'manifest.json', rollupOptions: { input: { - app: './src/_boot_.ts', + i18n: './src/i18n.ts', + entry: './src/_boot_.ts', }, external: externalPackages.map(p => p.match), + preserveEntrySignatures: 'allow-extension', output: { manualChunks: { vue: ['vue'], photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'], + // dependencies of i18n.ts + 'config': ['@@/js/config.js'], }, - chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js', - assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]', + entryFileNames: 'scripts/[hash:8].js', + chunkFileNames: 'scripts/[hash:8].js', + assetFileNames: 'assets/[hash:8][extname]', paths(id) { for (const p of externalPackages) { if (p.match.test(id)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8121822fb7..b1d75fe2ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -796,6 +796,9 @@ importers: eventemitter3: specifier: 5.0.1 version: 5.0.1 + execa: + specifier: 9.6.0 + version: 9.6.0 frontend-shared: specifier: workspace:* version: link:../frontend-shared @@ -1061,6 +1064,9 @@ importers: storybook-addon-misskey-theme: specifier: github:misskey-dev/storybook-addon-misskey-theme version: https://codeload.github.com/misskey-dev/storybook-addon-misskey-theme/tar.gz/cf583db098365b2ccc81a82f63ca9c93bc32b640(1169897c5e200f76aeb2e7696f1450e0) + tsx: + specifier: 4.20.3 + version: 4.20.3 vite-plugin-turbosnap: specifier: 1.0.3 version: 1.0.3 @@ -1080,6 +1086,37 @@ importers: specifier: 3.0.5 version: 3.0.5(typescript@5.9.2) + packages/frontend-builder: + dependencies: + estree-walker: + specifier: 3.0.3 + version: 3.0.3 + magic-string: + specifier: 0.30.17 + version: 0.30.17 + vite: + specifier: 7.0.6 + version: 7.0.6(@types/node@22.17.0)(sass@1.89.2)(terser@5.43.1)(tsx@4.20.3) + devDependencies: + '@types/estree': + specifier: 1.0.8 + version: 1.0.8 + '@types/node': + specifier: 22.17.0 + version: 22.17.0 + '@typescript-eslint/eslint-plugin': + specifier: 8.38.0 + version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.9.2))(eslint@9.31.0)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: 8.38.0 + version: 8.38.0(eslint@9.31.0)(typescript@5.9.2) + rollup: + specifier: 4.46.2 + version: 4.46.2 + typescript: + specifier: 5.9.2 + version: 5.9.2 + packages/frontend-embed: dependencies: '@discordapp/twemoji': @@ -1236,6 +1273,9 @@ importers: start-server-and-test: specifier: 2.0.12 version: 2.0.12 + tsx: + specifier: 4.20.3 + version: 4.20.3 vite-plugin-turbosnap: specifier: 1.0.3 version: 1.0.3 @@ -6771,14 +6811,6 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.4.4: - resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.4.6: resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} peerDependencies: @@ -15365,7 +15397,7 @@ snapshots: '@typescript-eslint/project-service@8.34.0(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) - '@typescript-eslint/types': 8.37.0 + '@typescript-eslint/types': 8.38.0 debug: 4.4.1(supports-color@10.0.0) typescript: 5.8.3 transitivePeerDependencies: @@ -15373,8 +15405,8 @@ snapshots: '@typescript-eslint/project-service@8.37.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) - '@typescript-eslint/types': 8.37.0 + '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) + '@typescript-eslint/types': 8.38.0 debug: 4.4.1(supports-color@10.0.0) typescript: 5.8.3 transitivePeerDependencies: @@ -15804,7 +15836,7 @@ snapshots: alien-signals: 2.0.6 muggle-string: 0.4.1 path-browserify: 1.0.1 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: typescript: 5.9.2 @@ -18072,10 +18104,6 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.4.4(picomatch@4.0.2): - optionalDependencies: - picomatch: 4.0.2 - fdir@6.4.6(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -22364,8 +22392,8 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.4.4(picomatch@4.0.2) - picomatch: 4.0.2 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 tinypool@1.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a034e257b..0fadcc60f7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - packages/backend - packages/frontend-shared - packages/frontend + - packages/frontend-builder - packages/frontend-embed - packages/icons-subsetter - packages/sw diff --git a/scripts/clean-all.js b/scripts/clean-all.js index dc391ecfd8..5a8f9eba23 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -13,6 +13,8 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend-builder/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend/node_modules', { recursive: true, force: true });