diff options
51 files changed, 860 insertions, 519 deletions
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d80c56f032..33a1ccbc76 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -111,10 +111,5 @@ jobs: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - - run: pnpm --filter misskey-js run build - if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'frontend' || matrix.workspace == 'sw' }} - - run: pnpm --filter misskey-reversi run build - if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'frontend' }} - - run: pnpm --filter misskey-bubble-game run build - if: ${{ matrix.workspace == 'frontend' }} + - run: pnpm --filter "${{ matrix.workspace }}^..." run build - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index e63d83997b..d75335f38f 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -3,10 +3,12 @@ name: Lint on: push: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml pull_request: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml jobs: @@ -14,15 +16,18 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4.3.0 - with: - fetch-depth: 0 - submodules: true - - name: Setup pnpm - uses: pnpm/action-setup@v4.2.0 - - uses: actions/setup-node@v4.4.0 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: pnpm i --frozen-lockfile - - run: cd locales && node verify.js + - uses: actions/checkout@v4.3.0 + with: + fetch-depth: 0 + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v4.4.0 + with: + node-version-file: ".node-version" + cache: "pnpm" + - run: pnpm i --frozen-lockfile + - run: pnpm --filter i18n build + - name: Verify Locales + working-directory: ./packages/i18n + run: pnpm run verify diff --git a/Dockerfile b/Dockerfile index 20e24d1dc2..a071970927 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-share COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"] +COPY --link ["packages/i18n/package.json", "./packages/i18n/"] COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] @@ -101,6 +102,7 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ diff --git a/locales/generateDTS.js b/locales/generateDTS.js deleted file mode 100644 index ab0613cc82..0000000000 --- a/locales/generateDTS.js +++ /dev/null @@ -1,232 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as yaml from 'js-yaml'; -import ts from 'typescript'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const parameterRegExp = /\{(\w+)\}/g; - -function createMemberType(item) { - if (typeof item !== 'string') { - return ts.factory.createTypeLiteralNode(createMembers(item)); - } - const parameters = Array.from( - item.matchAll(parameterRegExp), - ([, parameter]) => parameter, - ); - return parameters.length - ? ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createUnionTypeNode( - parameters.map((parameter) => - ts.factory.createStringLiteral(parameter), - ), - ), - ], - ) - : ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} - -function createMembers(record) { - return Object.entries(record).map(([k, v]) => { - const node = ts.factory.createPropertySignature( - undefined, - ts.factory.createStringLiteral(k), - undefined, - createMemberType(v), - ); - if (typeof v === 'string') { - ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - `* - * ${v.replace(/\n/g, '\n * ')} - `, - true, - ); - } - return node; - }); -} - -export default function generateDTS() { - const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); - const members = createMembers(locale); - const elements = [ - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('kParameters'), - undefined, - ts.factory.createTypeOperatorNode( - ts.SyntaxKind.UniqueKeyword, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.SymbolKeyword), - ), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createTypeAliasDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createTypeParameterDeclaration( - undefined, - ts.factory.createIdentifier('T'), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ), - ], - ts.factory.createIntersectionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - ts.factory.createComputedPropertyName( - ts.factory.createIdentifier('kParameters'), - ), - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('T'), - undefined, - ), - ), - ]) - ]), - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ILocale'), - undefined, - undefined, - [ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('_'), - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - undefined, - ), - ], - ts.factory.createUnionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - ), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ), - ], - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('Locale'), - undefined, - [ - ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ - ts.factory.createExpressionWithTypeArguments( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ], - members, - ), - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('locales'), - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('lang'), - undefined, - ts.factory.createKeywordTypeNode( - ts.SyntaxKind.StringKeyword, - ), - undefined, - ), - ], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - ), - ]), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createFunctionDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - ts.factory.createIdentifier('build'), - undefined, - [], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - undefined, - ), - ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), - ]; - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.MultiLineCommentTrivia, - ' eslint-disable ', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' This file is generated by locales/generateDTS.js', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' Do not edit this file directly.', - true, - ); - const printed = ts - .createPrinter({ - newLine: ts.NewLineKind.LineFeed, - }) - .printList( - ts.ListFormat.MultiLine, - ts.factory.createNodeArray(elements), - ts.createSourceFile( - 'index.d.ts', - '', - ts.ScriptTarget.ESNext, - true, - ts.ScriptKind.TS, - ), - ); - - fs.writeFileSync(`${__dirname}/index.d.ts`, printed, 'utf-8'); -} diff --git a/locales/index.js b/locales/index.js deleted file mode 100644 index 6d9cf4796b..0000000000 --- a/locales/index.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Languages Loader - */ - -import * as fs from 'node:fs'; -import * as yaml from 'js-yaml'; - -const merge = (...args) => args.reduce((a, c) => ({ - ...a, - ...c, - ...Object.entries(a) - .filter(([k]) => c && typeof c[k] === 'object') - .reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {}) -}), {}); - -const languages = [ - 'ar-SA', - 'ca-ES', - 'cs-CZ', - 'da-DK', - 'de-DE', - 'en-US', - 'es-ES', - 'fr-FR', - 'id-ID', - 'it-IT', - 'ja-JP', - 'ja-KS', - 'kab-KAB', - 'kn-IN', - 'ko-KR', - 'nl-NL', - 'no-NO', - 'pl-PL', - 'pt-PT', - 'ru-RU', - 'sk-SK', - 'th-TH', - 'tr-TR', - 'ug-CN', - 'uk-UA', - 'vi-VN', - 'zh-CN', - 'zh-TW', -]; - -const primaries = { - 'en': 'US', - 'ja': 'JP', - 'zh': 'CN', -}; - -// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く -const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); - -export function build() { - // vitestの挙動を調整するため、一度ローカル変数化する必要がある - // https://github.com/vitest-dev/vitest/issues/3988#issuecomment-1686599577 - // https://github.com/misskey-dev/misskey/pull/14057#issuecomment-2192833785 - const metaUrl = import.meta.url; - const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, metaUrl), 'utf-8'))) || {}, a), {}); - - // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す - const removeEmpty = (obj) => { - for (const [k, v] of Object.entries(obj)) { - if (v === '') { - delete obj[k]; - } else if (typeof v === 'object') { - removeEmpty(v); - } - } - return obj; - }; - removeEmpty(locales); - - return Object.entries(locales) - .reduce((a, [k, v]) => (a[k] = (() => { - const [lang] = k.split('-'); - switch (k) { - case 'ja-JP': return v; - case 'ja-KS': - case 'en-US': return merge(locales['ja-JP'], v); - default: return merge( - locales['ja-JP'], - locales['en-US'], - locales[`${lang}-${primaries[lang]}`] ?? {}, - v - ); - } - })(), a), {}); -} - -export default build(); diff --git a/locales/package.json b/locales/package.json deleted file mode 100644 index bedb411a91..0000000000 --- a/locales/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/locales/verify.js b/locales/verify.js deleted file mode 100644 index a8e9875d6e..0000000000 --- a/locales/verify.js +++ /dev/null @@ -1,53 +0,0 @@ -import locales from './index.js'; - -let valid = true; - -function writeError(type, lang, tree, data) { - process.stderr.write(JSON.stringify({ type, lang, tree, data })); - process.stderr.write('\n'); - valid = false; -} - -function verify(expected, actual, lang, trace) { - for (let key in expected) { - if (!Object.prototype.hasOwnProperty.call(actual, key)) { - continue; - } - if (typeof expected[key] === 'object') { - if (typeof actual[key] !== 'object') { - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'object', actual: typeof actual[key] }); - continue; - } - verify(expected[key], actual[key], lang, trace ? `${trace}.${key}` : key); - } else if (typeof expected[key] === 'string') { - switch (typeof actual[key]) { - case 'object': - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'string', actual: 'object' }); - break; - case 'undefined': - continue; - case 'string': - const expectedParameters = new Set(expected[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - const actualParameters = new Set(actual[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - for (let parameter of expectedParameters) { - if (!actualParameters.has(parameter)) { - writeError('missing_parameter', lang, trace ? `${trace}.${key}` : key, { parameter }); - } - } - } - } - } -} - -const { ['ja-JP']: original, ...verifiees } = locales; - -for (let lang in verifiees) { - if (!Object.prototype.hasOwnProperty.call(locales, lang)) { - continue; - } - verify(original, verifiees[lang], lang); -} - -if (!valid) { - process.exit(1); -} diff --git a/package.json b/package.json index 664c5e9e71..f11b24d0ad 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,17 @@ }, "packageManager": "pnpm@10.22.0", "workspaces": [ - "packages/frontend-shared", - "packages/frontend", - "packages/frontend-embed", - "packages/icons-subsetter", - "packages/backend", - "packages/sw", "packages/misskey-js", + "packages/i18n", "packages/misskey-reversi", - "packages/misskey-bubble-game" + "packages/misskey-bubble-game", + "packages/icons-subsetter", + "packages/frontend-shared", + "packages/frontend-builder", + "packages/sw", + "packages/backend", + "packages/frontend", + "packages/frontend-embed" ], "private": true, "scripts": { @@ -68,6 +70,7 @@ }, "devDependencies": { "@eslint/js": "9.39.1", + "i18n": "workspace:*", "@misskey-dev/eslint-plugin": "2.2.0", "@types/js-yaml": "4.0.9", "@types/node": "24.10.1", diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index eeade4569b..310ffec7ce 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown { } // TODO - //const locales = await import('../../../../locales/index.js'); + //const locales = await import('i18n'); // TODO: locale ファイルをクライアント用とサーバー用で分けたい @@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown { let untilTime = untilId ? this.toXListId(untilId) : null; let notifications: MiNotification[]; - for (;;) { + for (; ;) { let notificationsRes: [id: string, fields: string[]][]; // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 diff --git a/packages/frontend-builder/locale-inliner.ts b/packages/frontend-builder/locale-inliner.ts index 9bef465eeb..191d7250a6 100644 --- a/packages/frontend-builder/locale-inliner.ts +++ b/packages/frontend-builder/locale-inliner.ts @@ -10,7 +10,7 @@ import { collectModifications } from './locale-inliner/collect-modifications.js' import { applyWithLocale } from './locale-inliner/apply-with-locale.js'; import { blankLogger } from './logger.js'; import type { Logger } from './logger.js'; -import type { Locale } from '../../locales/index.js'; +import type { Locale } from 'i18n'; import type { Manifest as ViteManifest } from 'vite'; export class LocaleInliner { diff --git a/packages/frontend-builder/locale-inliner/apply-with-locale.ts b/packages/frontend-builder/locale-inliner/apply-with-locale.ts index 5e601cdf12..78851d3029 100644 --- a/packages/frontend-builder/locale-inliner/apply-with-locale.ts +++ b/packages/frontend-builder/locale-inliner/apply-with-locale.ts @@ -5,7 +5,7 @@ import MagicString from 'magic-string'; import { assertNever } from '../utils.js'; -import type { Locale, ILocale } from '../../../locales/index.js'; +import type { ILocale, Locale } from 'i18n'; import type { TextModification } from '../locale-inliner.js'; import type { Logger } from '../logger.js'; diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index d01e4c86ed..37dd133fe6 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -18,6 +18,7 @@ "typescript": "5.9.3" }, "dependencies": { + "i18n": "workspace:*", "estree-walker": "3.0.3", "magic-string": "0.30.21", "vite": "7.2.4" diff --git a/packages/frontend-embed/build.ts b/packages/frontend-embed/build.ts index 737233a4d0..4e1f588802 100644 --- a/packages/frontend-embed/build.ts +++ b/packages/frontend-embed/build.ts @@ -2,7 +2,7 @@ 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 locales from 'i18n'; import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { createLogger } from '../frontend-builder/logger'; diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 85e25c8faa..c27583cf86 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@discordapp/twemoji": "16.0.1", + "i18n": "workspace:*", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", "@rollup/pluginutils": "5.3.0", diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue index b621110ec9..9866e50958 100644 --- a/packages/frontend-embed/src/components/I18n.vue +++ b/packages/frontend-embed/src/components/I18n.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts" generic="T extends string | ParameterizedString"> import { computed, h } from 'vue'; -import type { ParameterizedString } from '../../../../locales/index.js'; +import type { ParameterizedString } from 'i18n'; const props = withDefaults(defineProps<{ src: T; @@ -25,7 +25,7 @@ const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: ( const parsed = computed(() => { let str = props.src as string; const value: (string | { arg: string; })[] = []; - for (;;) { + for (; ;) { const nextBracketOpen = str.indexOf('{'); const nextBracketClose = str.indexOf('}'); diff --git a/packages/frontend-embed/src/i18n.ts b/packages/frontend-embed/src/i18n.ts index 0b2b206b7e..6a3a18df17 100644 --- a/packages/frontend-embed/src/i18n.ts +++ b/packages/frontend-embed/src/i18n.ts @@ -6,7 +6,7 @@ import { markRaw } from 'vue'; import { I18n } from '@@/js/i18n.js'; import { locale } from '@@/js/locale.js'; -import type { Locale } from '../../../locales/index.js'; +import type { Locale } from 'i18n'; export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); diff --git a/packages/frontend-embed/vite.config.ts b/packages/frontend-embed/vite.config.ts index db4afb43a7..9e5c24f9d4 100644 --- a/packages/frontend-embed/vite.config.ts +++ b/packages/frontend-embed/vite.config.ts @@ -1,10 +1,10 @@ import path from 'path'; import pluginVue from '@vitejs/plugin-vue'; -import { type UserConfig, defineConfig } from 'vite'; +import { defineConfig, type UserConfig } from 'vite'; import * as yaml from 'js-yaml'; import { promises as fsp } from 'fs'; -import locales from '../../locales/index.js'; +import locales from 'i18n'; import meta from '../../package.json'; import packageInfo from './package.json' with { type: 'json' }; import pluginJson5 from './vite.json5.js'; diff --git a/packages/frontend-shared/js/i18n.ts b/packages/frontend-shared/js/i18n.ts index 3b103c4714..a42450ee88 100644 --- a/packages/frontend-shared/js/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ILocale, ParameterizedString } from '../../../locales/index.js'; +import type { ILocale, ParameterizedString } from 'i18n'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type TODO = any; diff --git a/packages/frontend-shared/js/locale.ts b/packages/frontend-shared/js/locale.ts index 87e3922fd9..38584fbed6 100644 --- a/packages/frontend-shared/js/locale.ts +++ b/packages/frontend-shared/js/locale.ts @@ -4,7 +4,7 @@ */ import { lang, version } from '@@/js/config.js'; -import type { Locale } from '../../../locales/index.js'; +import type { Locale } from 'i18n'; // ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null); diff --git a/packages/frontend-shared/js/store-boot-errors.ts b/packages/frontend-shared/js/store-boot-errors.ts index 31e6248445..2741339165 100644 --- a/packages/frontend-shared/js/store-boot-errors.ts +++ b/packages/frontend-shared/js/store-boot-errors.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Locale } from '../../../locales/index.js'; +import type { Locale } from 'i18n'; type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] }; diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index a407df6bf8..5806414930 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -34,6 +34,7 @@ "js-built" ], "dependencies": { + "i18n": "workspace:*", "misskey-js": "workspace:*", "vue": "3.5.24" } diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts index c823ff9bee..42918b44f3 100644 --- a/packages/frontend/.storybook/preload-locale.ts +++ b/packages/frontend/.storybook/preload-locale.ts @@ -4,7 +4,7 @@ */ import { writeFile } from 'node:fs/promises'; -import locales from '../../../locales/index.js'; +import locales from 'i18n'; await writeFile( new URL('locale.ts', import.meta.url), diff --git a/packages/frontend/build.ts b/packages/frontend/build.ts index 0401c2b9ba..0f605c745b 100644 --- a/packages/frontend/build.ts +++ b/packages/frontend/build.ts @@ -2,7 +2,7 @@ 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 locales from 'i18n'; import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { createLogger } from '../frontend-builder/logger'; diff --git a/packages/frontend/lib/vite-plugin-watch-locales.ts b/packages/frontend/lib/vite-plugin-watch-locales.ts index 8e209d27bd..372e9039d5 100644 --- a/packages/frontend/lib/vite-plugin-watch-locales.ts +++ b/packages/frontend/lib/vite-plugin-watch-locales.ts @@ -4,7 +4,7 @@ */ import path from 'node:path' -import locales from '../../../locales/index.js'; +import locales from 'i18n'; const localesDir = path.resolve(__dirname, '../../../locales') @@ -13,14 +13,14 @@ const localesDir = path.resolve(__dirname, '../../../locales') * @returns {import('vite').Plugin} */ export default function pluginWatchLocales() { - return { - name: 'watch-locales', + return { + name: 'watch-locales', - configureServer(server) { - const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`)); + configureServer(server) { + const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`)); - // watcherにパスを追加 - server.watcher.add(localeYmlPaths); + // watcherにパスを追加 + server.watcher.add(localeYmlPaths); server.watcher.on('change', (filePath) => { if (localeYmlPaths.includes(filePath)) { @@ -31,6 +31,6 @@ export default function pluginWatchLocales() { }) } }); - }, - }; + }, + }; } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ec7b39312a..c3baf5ea74 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,6 +20,7 @@ "@discordapp/twemoji": "16.0.1", "@github/webauthn-json": "2.1.1", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", + "i18n": "workspace:*", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.3", diff --git a/packages/frontend/src/components/global/I18n.vue b/packages/frontend/src/components/global/I18n.vue index 6b7723e6ac..9866e50958 100644 --- a/packages/frontend/src/components/global/I18n.vue +++ b/packages/frontend/src/components/global/I18n.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts" generic="T extends string | ParameterizedString"> import { computed, h } from 'vue'; -import type { ParameterizedString } from '../../../../../locales/index.js'; +import type { ParameterizedString } from 'i18n'; const props = withDefaults(defineProps<{ src: T; @@ -25,7 +25,7 @@ const slots = defineSlots<T extends ParameterizedString<infer R> ? { [K in R]: ( const parsed = computed(() => { let str = props.src as string; const value: (string | { arg: string; })[] = []; - for (;;) { + for (; ;) { const nextBracketOpen = str.indexOf('{'); const nextBracketClose = str.indexOf('}'); diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 0b2b206b7e..6a3a18df17 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -6,7 +6,7 @@ import { markRaw } from 'vue'; import { I18n } from '@@/js/i18n.js'; import { locale } from '@@/js/locale.js'; -import type { Locale } from '../../../locales/index.js'; +import type { Locale } from 'i18n'; export const i18n = markRaw(new I18n<Locale>(locale, _DEV_)); diff --git a/packages/frontend/test/i18n.test.ts b/packages/frontend/test/i18n.test.ts index a51dfc6c4e..14b89f898d 100644 --- a/packages/frontend/test/i18n.test.ts +++ b/packages/frontend/test/i18n.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので -import type { ParameterizedString } from '../../../locales/index.js'; +import type { ParameterizedString } from 'i18n'; // TODO: このテストはfrontend-sharedに移動する diff --git a/packages/frontend/test/init.ts b/packages/frontend/test/init.ts index e38338cf95..28848f6c2f 100644 --- a/packages/frontend/test/init.ts +++ b/packages/frontend/test/init.ts @@ -7,13 +7,13 @@ import { vi } from 'vitest'; import createFetchMock from 'vitest-fetch-mock'; import type { Ref } from 'vue'; import { ref } from 'vue'; +// Set i18n +import locales from 'i18n'; +import { updateI18n } from '@/i18n.js'; const fetchMocker = createFetchMock(vi); fetchMocker.enableMocks(); -// Set i18n -import locales from '../../../locales/index.js'; -import { updateI18n } from '@/i18n.js'; updateI18n(locales['en-US']); // XXX: misskey-js panics if WebSocket is not defined diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 6f320e99c9..c9c20b23ea 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -2,18 +2,18 @@ import path from 'path'; import pluginReplace from '@rollup/plugin-replace'; import pluginVue from '@vitejs/plugin-vue'; import pluginGlsl from 'vite-plugin-glsl'; -import { defineConfig } from 'vite'; import type { UserConfig } from 'vite'; +import { defineConfig } from 'vite'; import * as yaml from 'js-yaml'; import { promises as fsp } from 'fs'; -import locales from '../../locales/index.js'; +import locales from 'i18n'; import meta from '../../package.json'; import packageInfo from './package.json' with { type: 'json' }; import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js'; 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 pluginCreateSearchIndex 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'; diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 0000000000..1013b84ea6 --- /dev/null +++ b/packages/i18n/README.md @@ -0,0 +1,5 @@ +# Misskey i18n + +Misskey の言語ファイル本体 (ja-JP.yml など) はリポジトリ直下の `/locales` に置かれており、そこから Crowdin 連携やビルド資産が生成されます。 + +このパッケージは Misskey モノレポ内で、これらの言語ファイルを共通で扱うためのヘルパー群や型情報をまとめる位置づけです。バックエンド / フロントエンド / Service Worker など各パッケージが同じ翻訳データと型定義を利用できるようにすることを目的としており、npm での外部配布は想定していません。 diff --git a/packages/i18n/build.ts b/packages/i18n/build.ts new file mode 100644 index 0000000000..a6bbf7dc63 --- /dev/null +++ b/packages/i18n/build.ts @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { watch as chokidarWatch } from 'chokidar'; +import * as esbuild from 'esbuild'; +import { build } from 'esbuild'; +import { execa } from 'execa'; +import { globSync } from 'glob'; +import { generateLocaleInterface } from './scripts/generateLocaleInterface.js'; +import type { BuildOptions, BuildResult, Plugin, PluginBuild } from 'esbuild'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); +const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); +const _rootPackageDir = resolve(_dirname, '../../'); +const _rootPackage = JSON.parse(fs.readFileSync(resolve(_rootPackageDir, 'package.json'), 'utf-8')); +const _frontendLocalesDir = resolve(_dirname, '../../built/_frontend_dist_/locales'); +const _localesDir = resolve(_rootPackageDir, 'locales'); + +const entryPoints = globSync('./src/**/**.{ts,tsx}'); + +const options: BuildOptions = { + entryPoints, + minify: process.env.NODE_ENV === 'production', + sourceRoot: 'src', + outdir: './built', + target: 'es2022', + platform: 'node', + format: 'esm', + sourcemap: 'linked', +}; + +// コマンドライン引数を取得 +const args = process.argv.slice(2).map(arg => arg.toLowerCase()); + +// built配下をすべて削除する +if (!args.includes('--no-clean')) { + fs.rmSync('./built', { recursive: true, force: true }); +} + +if (args.includes('--watch')) { + await watchSrc(); +} else { + await buildSrc(); +} + +function copyLocales(): void { + const srcDir = _localesDir; + const destDir = resolve(_dirname, 'built/locales'); + + fs.mkdirSync(destDir, { recursive: true }); + + const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.yml')); + for (const file of files) { + fs.copyFileSync(resolve(srcDir, file), resolve(destDir, file)); + } + console.log(`[${_package.name}] locales copied (${files.length} files).`); +} + +/** + * フロントエンド用の locale JSON を書き出す + * Service Worker が HTTP 経由で取得するために必要 + */ +async function writeFrontendLocalesJson(): Promise<void> { + // 動的 import でビルド済みモジュールから読み込み(循環参照回避) + const { writeFrontendLocalesJson: write } = await import('./built/index.js'); + await write(_frontendLocalesDir, _rootPackage.version); + console.log(`[${_package.name}] frontend locales JSON written to ${_frontendLocalesDir}`); +} + +async function buildSrc(): Promise<void> { + console.log(`[${_package.name}] start building...`); + + await generateLocaleInterface(_localesDir); + + await build(options) + .then(() => { + console.log(`[${_package.name}] build succeeded.`); + }) + .catch((err) => { + process.stderr.write(err.stderr); + process.exit(1); + }); + + copyLocales(); + await writeFrontendLocalesJson(); + + if (process.env.NODE_ENV === 'production') { + console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`); + } else { + await buildDts(); + } + + console.log(`[${_package.name}] finish building.`); +} + +function buildDts(): Promise<unknown> { + return execa( + 'tsc', + [ + '--project', 'tsconfig.json', + '--rootDir', 'src', + '--outDir', 'built', + '--declaration', 'true', + '--emitDeclarationOnly', 'true', + ], + { + stdout: process.stdout, + stderr: process.stderr, + }, + ); +} + +async function watchSrc(): Promise<void> { + const localesWatcher = chokidarWatch(_localesDir, { + ignoreInitial: true, + }); + localesWatcher.on('all', async (event, path) => { + if (!path.endsWith('.yml')) return; + console.log(`[${_package.name}] locales changed: ${event} ${path}`); + copyLocales(); + await writeFrontendLocalesJson(); + await generateLocaleInterface(_localesDir); + }); + + const plugins: Plugin[] = [{ + name: 'gen-dts', + setup(build: PluginBuild) { + build.onStart(() => { + console.log(`[${_package.name}] detect changed...`); + }); + build.onEnd(async (result: BuildResult) => { + if (result.errors.length > 0) { + console.error(`[${_package.name}] watch build failed:`, result); + return; + } + await buildDts(); + }); + }, + }]; + + console.log(`[${_package.name}] start watching...`); + + const context = await esbuild.context({ ...options, plugins }); + await context.watch(); + + await new Promise((resolve, reject) => { + process.on('SIGHUP', resolve); + process.on('SIGINT', resolve); + process.on('SIGTERM', resolve); + process.on('uncaughtException', reject); + process.on('exit', resolve); + }).finally(async () => { + await context.dispose(); + await localesWatcher.close(); + console.log(`[${_package.name}] finish watching.`); + }); +} diff --git a/packages/i18n/eslint.config.js b/packages/i18n/eslint.config.js new file mode 100644 index 0000000000..f18bc4a4be --- /dev/null +++ b/packages/i18n/eslint.config.js @@ -0,0 +1,35 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + 'built', + 'coverage', + 'vitest.config.ts', + 'test', + 'test-d', + 'generator', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.eslint.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + files: ['src/autogen/**/*.ts', 'src/autogen/**/*.tsx'], + rules: { + '@stylistic/indent': 'off', + }, + }, +]; diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000000..d06e485da0 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,42 @@ +{ + "name": "i18n", + "type": "module", + "private": true, + "main": "./built/index.js", + "types": "./built/index.d.ts", + "exports": { + ".": { + "types": "./built/index.d.ts", + "import": "./built/index.js" + } + }, + "scripts": { + "generate": "tsx scripts/generateLocaleInterface.ts", + "verify": "tsx scripts/verify.ts", + "build": "tsx ./build.ts", + "watch": "nodemon -w package.json -e json --exec \"tsx ./build.ts --watch\"", + "tsd": "tsd", + "typecheck": "tsc --noEmit", + "lint": "pnpm typecheck && pnpm eslint", + "lint:fix": "pnpm eslint --fix" + }, + "files": [ + "built" + ], + "devDependencies": { + "@types/js-yaml": "4.0.9", + "@types/node": "24.10.1", + "@typescript-eslint/eslint-plugin": "8.47.0", + "@typescript-eslint/parser": "8.47.0", + "chokidar": "4.0.3", + "esbuild": "0.27.0", + "execa": "9.6.0", + "glob": "11.1.0", + "nodemon": "3.1.11", + "tsx": "4.20.6", + "typescript": "5.9.3" + }, + "dependencies": { + "js-yaml": "4.1.1" + } +} diff --git a/packages/i18n/scripts/generateLocaleInterface.ts b/packages/i18n/scripts/generateLocaleInterface.ts new file mode 100644 index 0000000000..1c0f5c6a79 --- /dev/null +++ b/packages/i18n/scripts/generateLocaleInterface.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import * as yaml from 'js-yaml'; +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const parameterRegExp = /\{(\w+)\}/g; + +interface LocaleRecord { + [key: string]: string | LocaleRecord; +} + +function createMemberType(item: string | LocaleRecord): ts.TypeNode { + if (typeof item !== 'string') { + return ts.factory.createTypeLiteralNode(createMembers(item)); + } + const parameters = Array.from( + item.matchAll(parameterRegExp), + ([, parameter]) => parameter, + ); + return parameters.length + ? ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('ParameterizedString'), + [ + ts.factory.createUnionTypeNode( + parameters.map((parameter) => + ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(parameter)), + ), + ), + ], + ) + : ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); +} + +function createMembers(record: LocaleRecord): ts.TypeElement[] { + return Object.entries(record).map(([k, v]) => { + const node = ts.factory.createPropertySignature( + undefined, + ts.factory.createStringLiteral(k), + undefined, + createMemberType(v), + ); + if (typeof v === 'string') { + ts.addSyntheticLeadingComment( + node, + ts.SyntaxKind.MultiLineCommentTrivia, + `* + * ${v.replace(/\n/g, '\n * ')} + `, + true, + ); + } + return node; + }); +} + +export async function generateLocaleInterface(localesDir: string): Promise<void> { + const locale = yaml.load(fs.readFileSync(`${localesDir}/ja-JP.yml`, 'utf-8').toString()) as LocaleRecord; + const members = createMembers(locale); + + const elements: ts.Statement[] = [ + ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier( + true, + undefined, + ts.factory.createIdentifier('ILocale'), + ), + ts.factory.createImportSpecifier( + true, + undefined, + ts.factory.createIdentifier('ParameterizedString'), + ), + ]), + ), + ts.factory.createStringLiteral('../types.js'), + undefined, + ), + ts.factory.createInterfaceDeclaration( + [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier('Locale'), + undefined, + [ + ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ + ts.factory.createExpressionWithTypeArguments( + ts.factory.createIdentifier('ILocale'), + undefined, + ), + ]), + ], + members, + ), + ]; + + ts.addSyntheticLeadingComment( + elements[0], + ts.SyntaxKind.MultiLineCommentTrivia, + ' eslint-disable ', + true, + ); + ts.addSyntheticLeadingComment( + elements[0], + ts.SyntaxKind.SingleLineCommentTrivia, + ' This file is generated by scripts/generateLocaleInterface.ts', + true, + ); + ts.addSyntheticLeadingComment( + elements[0], + ts.SyntaxKind.SingleLineCommentTrivia, + ' Do not edit this file directly.', + true, + ); + + const printed = ts + .createPrinter({ + newLine: ts.NewLineKind.LineFeed, + }) + .printList( + ts.ListFormat.MultiLine, + ts.factory.createNodeArray(elements), + ts.createSourceFile( + 'locale.ts', + '', + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS, + ), + ); + + const autogenDir = `${__dirname}/../src/autogen`; + fs.mkdirSync(autogenDir, { recursive: true }); + + // 一瞬ファイルが存在しなくなって途切れる→不安定になるらしいので、リネームで対処 + fs.writeFileSync(`${autogenDir}/_locale.ts`, printed, 'utf-8'); + fs.renameSync(`${autogenDir}/_locale.ts`, `${autogenDir}/locale.ts`); +} + +// スクリプトとして直接実行された場合 +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain) { + await generateLocaleInterface(resolve(__dirname, '../../../locales')); +} diff --git a/packages/i18n/scripts/verify.ts b/packages/i18n/scripts/verify.ts new file mode 100644 index 0000000000..877966e49f --- /dev/null +++ b/packages/i18n/scripts/verify.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +let valid = true; + +interface LocaleRecord { + [key: string]: string | LocaleRecord; +} + +interface ErrorData { + expected?: string; + actual?: string; + parameter?: string; +} + +function writeError(type: string, lang: string, tree: string, data: ErrorData): void { + process.stderr.write(JSON.stringify({ type, lang, tree, data })); + process.stderr.write('\n'); + valid = false; +} + +function verify(expected: LocaleRecord, actual: LocaleRecord, lang: string, trace?: string): void { + for (const key in expected) { + if (!Object.prototype.hasOwnProperty.call(actual, key)) { + continue; + } + if (typeof expected[key] === 'object') { + if (typeof actual[key] !== 'object') { + writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'object', actual: typeof actual[key] }); + continue; + } + verify(expected[key] as LocaleRecord, actual[key] as LocaleRecord, lang, trace ? `${trace}.${key}` : key); + } else if (typeof expected[key] === 'string') { + switch (typeof actual[key]) { + case 'object': + writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'string', actual: 'object' }); + break; + case 'undefined': + continue; + case 'string': { + const expectedParameters = new Set((expected[key] as string).match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); + const actualParameters = new Set((actual[key] as string).match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); + for (const parameter of expectedParameters) { + if (!actualParameters.has(parameter)) { + writeError('missing_parameter', lang, trace ? `${trace}.${key}` : key, { parameter }); + } + } + } + } + } + } +} + +// index.tsはtsのまま動かすことを想定していない(ビルド成果物を外部に公開する). +// よってビルド後のものを検証する +const locales = await import('../built/index.js'); +const { 'ja-JP': original, ...verifiees } = locales as unknown as Record<string, LocaleRecord>; + +for (const lang in verifiees) { + if (!Object.prototype.hasOwnProperty.call(locales, lang)) { + continue; + } + verify(original, verifiees[lang], lang); +} + +if (!valid) { + process.exit(1); +} diff --git a/locales/index.d.ts b/packages/i18n/src/autogen/locale.ts index 7f0efd22e8..8f94aab555 100644 --- a/locales/index.d.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -1,13 +1,7 @@ /* eslint-disable */ -// This file is generated by locales/generateDTS.js +// This file is generated by scripts/generateLocaleInterface.ts // Do not edit this file directly. -declare const kParameters: unique symbol; -export type ParameterizedString<T extends string = string> = string & { - [kParameters]: T; -}; -export interface ILocale { - [_: string]: string | ParameterizedString | ILocale; -} +import { type ILocale, type ParameterizedString } from "../types.js"; export interface Locale extends ILocale { /** * 日本語 @@ -13089,8 +13083,3 @@ export interface Locale extends ILocale { "mfm": string; }; } -declare const locales: { - [lang: string]: Locale; -}; -export function build(): Locale; -export default locales; diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 0000000000..e428267748 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Languages Loader + */ + +import * as fs from 'node:fs'; +import * as yaml from 'js-yaml'; +import type { Locale } from './autogen/locale.js'; +import type { ILocale, ParameterizedString } from './types.js'; + +const languages = [ + 'ar-SA', + 'ca-ES', + 'cs-CZ', + 'da-DK', + 'de-DE', + 'en-US', + 'es-ES', + 'fr-FR', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ja-KS', + 'kab-KAB', + 'kn-IN', + 'ko-KR', + 'nl-NL', + 'no-NO', + 'pl-PL', + 'pt-PT', + 'ru-RU', + 'sk-SK', + 'th-TH', + 'tr-TR', + 'ug-CN', + 'uk-UA', + 'vi-VN', + 'zh-CN', + 'zh-TW', +] as const; + +type Language = typeof languages[number]; + +const primaries = { + 'en': 'US', + 'ja': 'JP', + 'zh': 'CN', +} as const satisfies Record<string, string>; + +type PrimaryLang = keyof typeof primaries; + +type Locales = Record<Language, ILocale>; + +/** + * オブジェクトを再帰的にマージする + */ +function merge<T extends ILocale>(...args: (T | ILocale | undefined)[]): T { + return args.reduce<ILocale>((a, c) => ({ + ...a, + ...c, + ...Object.entries(a) + .filter(([k]) => c && typeof c[k] === 'object') + .reduce<Record<string, ILocale[string]>>((acc, [k, v]) => { + acc[k] = merge(v as ILocale, (c as ILocale)[k] as ILocale); + return acc; + }, {}), + }), {} as ILocale) as T; +} + +/** + * 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く + */ +function clean (text: string) { + return text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); +} + +/** + * 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す + */ +function removeEmpty<T extends ILocale>(obj: T): T { + for (const [k, v] of Object.entries(obj)) { + if (v === '') { + delete obj[k]; + } else if (typeof v === 'object') { + removeEmpty(v as ILocale); + } + } + return obj; +} + +function build(): Record<Language, Locale> { + // vitestの挙動を調整するため、一度ローカル変数化する必要がある + // https://github.com/vitest-dev/vitest/issues/3988#issuecomment-1686599577 + // https://github.com/misskey-dev/misskey/pull/14057#issuecomment-2192833785 + const metaUrl = import.meta.url; + const locales = languages.reduce<Locales>((a, lang) => { + a[lang] = (yaml.load(clean(fs.readFileSync(new URL(`./locales/${lang}.yml`, metaUrl), 'utf-8'))) ?? {}) as ILocale; + return a; + }, {} as Locales); + + removeEmpty(locales); + + return Object.entries(locales).reduce<Record<Language, Locale>>((a, [k, v]) => { + const lang = k.split('-')[0]; + const key = k as Language; + + switch (key) { + case 'ja-JP': + a[key] = v as Locale; + break; + case 'ja-KS': + case 'en-US': + a[key] = merge<Locale>(locales['ja-JP'] as Locale, v); + break; + default: { + const primaryLang = lang as PrimaryLang; + const primaryKey = (lang in primaries ? `${lang}-${primaries[primaryLang]}` : undefined) as Language | undefined; + a[key] = merge<Locale>( + locales['ja-JP'] as Locale, + locales['en-US'], + primaryKey ? locales[primaryKey] : {}, + v, + ); + break; + } + } + + return a; + }, {} as Record<Language, Locale>); +} + +const locales = build() as { + [lang: string]: Locale; +}; + +/** + * フロントエンド用の locale JSON を書き出す + * Service Worker が HTTP 経由で取得するために必要 + * @param destDir 出力先ディレクトリ(例: built/_frontend_dist_/locales) + * @param version バージョン文字列(ファイル名とJSON内に埋め込まれる) + */ +async function writeFrontendLocalesJson(destDir: string, version: string): Promise<void> { + const { mkdir, writeFile } = await import('node:fs/promises'); + const { resolve } = await import('node:path'); + + await mkdir(destDir, { recursive: true }); + + const builtLocales = build(); + const v = { '_version_': version }; + + for (const [lang, locale] of Object.entries(builtLocales)) { + await writeFile( + resolve(destDir, `${lang}.${version}.json`), + JSON.stringify({ ...locale, ...v }), + 'utf-8', + ); + } +} + +export { locales, build, writeFrontendLocalesJson }; +export type { Language, Locale, ILocale, ParameterizedString }; +export default locales; diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts new file mode 100644 index 0000000000..cf9568e792 --- /dev/null +++ b/packages/i18n/src/types.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare const kParameters: unique symbol; + +export type ParameterizedString<T extends string = string> = string & { + [kParameters]: T; +}; + +export interface ILocale { + [_: string]: string | ParameterizedString | ILocale; +} diff --git a/packages/i18n/tsconfig.eslint.json b/packages/i18n/tsconfig.eslint.json new file mode 100644 index 0000000000..5aea206cd1 --- /dev/null +++ b/packages/i18n/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "scripts/**/*.ts", + "build.ts" + ] +}
\ No newline at end of file diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 0000000000..31cd39b8ba --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +}
\ No newline at end of file diff --git a/packages/sw/build.js b/packages/sw/build.js index a9c2e428c0..d5ca028da5 100644 --- a/packages/sw/build.js +++ b/packages/sw/build.js @@ -7,8 +7,9 @@ import { fileURLToPath } from 'node:url'; import * as esbuild from 'esbuild'; -import locales from '../../locales/index.js'; +import locales from 'i18n'; import meta from '../../package.json' with { type: 'json' }; + const watch = process.argv[2]?.includes('watch'); const __dirname = fileURLToPath(new URL('.', import.meta.url)); diff --git a/packages/sw/package.json b/packages/sw/package.json index 51d78511c2..0fe10256ce 100644 --- a/packages/sw/package.json +++ b/packages/sw/package.json @@ -9,6 +9,7 @@ "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { + "i18n": "workspace:*", "esbuild": "0.27.0", "idb-keyval": "6.2.2", "misskey-js": "workspace:*" diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts index 3000160e41..40b6aa4e7b 100644 --- a/packages/sw/src/scripts/lang.ts +++ b/packages/sw/src/scripts/lang.ts @@ -8,7 +8,7 @@ */ import { get, set } from 'idb-keyval'; import { I18n } from '@@/js/i18n.js'; -import type { Locale } from '../../../../locales/index.js'; +import type { Locale } from 'i18n'; class SwLang { public cacheName = `mk-cache-${_VERSION_}`; diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index 298af4b4b6..5cece73401 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -7,7 +7,7 @@ import { get } from 'idb-keyval'; import * as Misskey from 'misskey-js'; import type { PushNotificationDataMap } from '@/types.js'; import type { I18n } from '@@/js/i18n.js'; -import type { Locale } from '../../../locales/index.js'; +import type { Locale } from 'i18n'; import { createEmptyNotification, createNotification } from '@/scripts/create-notification.js'; import { swLang } from '@/scripts/lang.js'; import * as swos from '@/scripts/operations.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3793bb79c1..02a6124d7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: globals: specifier: 16.5.0 version: 16.5.0 + i18n: + specifier: workspace:* + version: link:packages/i18n ncp: specifier: 2.0.0 version: 2.0.0 @@ -785,6 +788,9 @@ importers: frontend-shared: specifier: workspace:* version: link:../frontend-shared + i18n: + specifier: workspace:* + version: link:../i18n icons-subsetter: specifier: workspace:* version: link:../icons-subsetter @@ -1086,6 +1092,9 @@ importers: estree-walker: specifier: 3.0.3 version: 3.0.3 + i18n: + specifier: workspace:* + version: link:../i18n magic-string: specifier: 0.30.21 version: 0.30.21 @@ -1147,6 +1156,9 @@ importers: frontend-shared: specifier: workspace:* version: link:../frontend-shared + i18n: + specifier: workspace:* + version: link:../i18n icons-subsetter: specifier: workspace:* version: link:../icons-subsetter @@ -1286,6 +1298,9 @@ importers: packages/frontend-shared: dependencies: + i18n: + specifier: workspace:* + version: link:../i18n misskey-js: specifier: workspace:* version: link:../misskey-js @@ -1318,6 +1333,46 @@ importers: specifier: 10.2.0 version: 10.2.0(eslint@9.39.1) + packages/i18n: + dependencies: + js-yaml: + specifier: 4.1.1 + version: 4.1.1 + devDependencies: + '@types/js-yaml': + specifier: 4.0.9 + version: 4.0.9 + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.47.0 + version: 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: 8.47.0 + version: 8.47.0(eslint@9.39.1)(typescript@5.9.3) + chokidar: + specifier: 4.0.3 + version: 4.0.3 + esbuild: + specifier: 0.27.0 + version: 0.27.0 + execa: + specifier: 9.6.0 + version: 9.6.0 + glob: + specifier: 11.1.0 + version: 11.1.0 + nodemon: + specifier: 3.1.11 + version: 3.1.11 + tsx: + specifier: 4.20.6 + version: 4.20.6 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/icons-subsetter: dependencies: '@tabler/icons-webfont': @@ -1519,6 +1574,9 @@ importers: esbuild: specifier: 0.27.0 version: 0.27.0 + i18n: + specifier: workspace:* + version: link:../i18n idb-keyval: specifier: 6.2.2 version: 6.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5607f7533..9d43518ff6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - packages/frontend - packages/frontend-builder - packages/frontend-embed + - packages/i18n - packages/icons-subsetter - packages/sw - packages/misskey-js diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs index e610a72380..34883e3513 100644 --- a/scripts/build-assets.mjs +++ b/scripts/build-assets.mjs @@ -11,9 +11,7 @@ import * as yaml from 'js-yaml'; import postcss from 'postcss'; import * as terser from 'terser'; -import { build as buildLocales } from '../locales/index.js'; -import generateDTS from '../locales/generateDTS.js'; -import meta from '../package.json' with { type: "json" }; +import { locales } from 'i18n'; import buildTarball from './tarball.mjs'; const configDir = fileURLToPath(new URL('../.config', import.meta.url)); @@ -23,86 +21,59 @@ const configPath = process.env.MISSKEY_CONFIG_YML ? path.resolve(configDir, 'test.yml') : path.resolve(configDir, 'default.yml'); -let locales = buildLocales(); - async function loadConfig() { return fs.readFile(configPath, 'utf-8').then(data => yaml.load(data)).catch(() => null); } async function copyFrontendFonts() { - await fs.cp('./packages/frontend/node_modules/three/examples/fonts', './built/_frontend_dist_/fonts', { dereference: true, recursive: true }); -} - -async function copyFrontendLocales() { - generateDTS(); - - await fs.mkdir('./built/_frontend_dist_/locales', { recursive: true }); - - const v = { '_version_': meta.version }; - - for (const [lang, locale] of Object.entries(locales)) { - await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8'); - } + await fs.cp('./packages/frontend/node_modules/three/examples/fonts', './built/_frontend_dist_/fonts', { dereference: true, recursive: true }); } async function copyBackendViews() { - await fs.cp('./packages/backend/src/server/web/views', './packages/backend/built/server/web/views', { recursive: true }); + await fs.cp('./packages/backend/src/server/web/views', './packages/backend/built/server/web/views', { recursive: true }); } async function buildBackendScript() { - await fs.mkdir('./packages/backend/built/server/web', { recursive: true }); + await fs.mkdir('./packages/backend/built/server/web', { recursive: true }); - for (const file of [ - './packages/backend/src/server/web/boot.js', - './packages/backend/src/server/web/boot.embed.js', - './packages/backend/src/server/web/bios.js', - './packages/backend/src/server/web/cli.js', - './packages/backend/src/server/web/error.js', - ]) { - let source = await fs.readFile(file, { encoding: 'utf-8' }); - source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales))); - const { code } = await terser.minify(source, { toplevel: true }); - await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, code); - } + for (const file of [ + './packages/backend/src/server/web/boot.js', + './packages/backend/src/server/web/boot.embed.js', + './packages/backend/src/server/web/bios.js', + './packages/backend/src/server/web/cli.js', + './packages/backend/src/server/web/error.js', + ]) { + let source = await fs.readFile(file, { encoding: 'utf-8' }); + source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales))); + const { code } = await terser.minify(source, { toplevel: true }); + await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, code); + } } async function buildBackendStyle() { - await fs.mkdir('./packages/backend/built/server/web', { recursive: true }); + await fs.mkdir('./packages/backend/built/server/web', { recursive: true }); - for (const file of [ - './packages/backend/src/server/web/style.css', - './packages/backend/src/server/web/style.embed.css', - './packages/backend/src/server/web/bios.css', - './packages/backend/src/server/web/cli.css', - './packages/backend/src/server/web/error.css' - ]) { - const source = await fs.readFile(file, { encoding: 'utf-8' }); - const { css } = await postcss([cssnano({ zindex: false })]).process(source, { from: undefined }); - await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, css); - } + for (const file of [ + './packages/backend/src/server/web/style.css', + './packages/backend/src/server/web/style.embed.css', + './packages/backend/src/server/web/bios.css', + './packages/backend/src/server/web/cli.css', + './packages/backend/src/server/web/error.css' + ]) { + const source = await fs.readFile(file, { encoding: 'utf-8' }); + const { css } = await postcss([cssnano({ zindex: false })]).process(source, { from: undefined }); + await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, css); + } } async function build() { - await Promise.all([ - copyFrontendFonts(), - copyFrontendLocales(), - copyBackendViews(), - buildBackendScript(), - buildBackendStyle(), + await Promise.all([ + copyFrontendFonts(), + copyBackendViews(), + buildBackendScript(), + buildBackendStyle(), loadConfig().then(config => config?.publishTarballInsteadOfProvideRepositoryUrl && buildTarball()), - ]); + ]); } await build(); - -if (process.argv.includes('--watch')) { - const watcher = fs.watch('./locales'); - for await (const event of watcher) { - const filename = event.filename?.replaceAll('\\', '/'); - if (/^[a-z]+-[A-Z]+\.yml/.test(filename)) { - console.log(`update ${filename} ...`) - locales = buildLocales(); - await copyFrontendLocales() - } - } -} diff --git a/scripts/clean-all.js b/scripts/clean-all.js index 5a8f9eba23..839ea3ba1c 100644 --- a/scripts/clean-all.js +++ b/scripts/clean-all.js @@ -24,6 +24,9 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/i18n/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/i18n/node_modules', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/misskey-js/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-js/node_modules', { recursive: true, force: true }); diff --git a/scripts/clean.js b/scripts/clean.js index 69a8df76af..5cce8bacab 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -11,6 +11,7 @@ const fs = require('fs'); fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/icons-subsetter/built', { recursive: true, force: true }); + fs.rmSync(__dirname + '/../packages/i18n/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-js/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/misskey-reversi/built', { recursive: true, force: true }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index e500510b9e..b54004132a 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -16,6 +16,13 @@ await execa('pnpm', ['clean'], { stderr: process.stderr, }); +// アセットのビルドで依存しているので一番最初に必要 +await execa('pnpm', ['--filter', 'i18n', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + await Promise.all([ execa('pnpm', ['build-pre'], { cwd: _dirname + '/../', @@ -38,6 +45,11 @@ await Promise.all([ stdout: process.stdout, stderr: process.stderr, }), + execa('pnpm', ['--filter', 'misskey-js', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), ]); execa('pnpm', ['build-pre', '--watch'], { @@ -88,6 +100,12 @@ execa('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], { stderr: process.stderr, }); +execa('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, +}); + execa('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], { cwd: _dirname + '/../', stdout: process.stdout, |