refactor: localesをworkspace管理下のパッケージに (#16895)

* refactor: localesをworkspace管理下のパッケージに

* fix copilot review

* move

* move

* rename

* fix ci

* revert unwanted indent changes

* fix

* fix

* fix

* fix

* 間違えてコミットしていたのを戻す

* 不要

* 追加漏れ

* ymlの場所だけ戻す

* localesの位置を戻したのでこの差分は不要

* 内容的にlocalesにある方が正しい

* i18nパッケージ用のREADME.mdを用意

* fix locale.yml

* fix locale.yml

---------

Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
This commit is contained in:
おさむのひと
2025-11-30 13:27:44 +09:00
committed by GitHub
parent 32b5583432
commit fe01a5a28f
51 changed files with 860 additions and 519 deletions

View File

@@ -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も参照

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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"

View File

@@ -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';

View File

@@ -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",

View File

@@ -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('}');

View File

@@ -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_));

View File

@@ -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';

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'] };

View File

@@ -34,6 +34,7 @@
"js-built"
],
"dependencies": {
"i18n": "workspace:*",
"misskey-js": "workspace:*",
"vue": "3.5.24"
}

View File

@@ -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),

View File

@@ -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';

View File

@@ -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() {
})
}
});
},
};
},
};
}

View File

@@ -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",

View File

@@ -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('}');

View File

@@ -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_));

View File

@@ -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に移動する

View File

@@ -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

View File

@@ -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';

5
packages/i18n/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Misskey i18n
Misskey の言語ファイル本体 (ja-JP.yml など) はリポジトリ直下の `/locales` に置かれており、そこから Crowdin 連携やビルド資産が生成されます。
このパッケージは Misskey モノレポ内で、これらの言語ファイルを共通で扱うためのヘルパー群や型情報をまとめる位置づけです。バックエンド / フロントエンド / Service Worker など各パッケージが同じ翻訳データと型定義を利用できるようにすることを目的としており、npm での外部配布は想定していません。

163
packages/i18n/build.ts Normal file
View File

@@ -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.`);
});
}

View File

@@ -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',
},
},
];

View File

@@ -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"
}
}

View File

@@ -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'));
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

166
packages/i18n/src/index.ts Normal file
View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"scripts/**/*.ts",
"build.ts"
]
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -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));

View File

@@ -9,6 +9,7 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"i18n": "workspace:*",
"esbuild": "0.27.0",
"idb-keyval": "6.2.2",
"misskey-js": "workspace:*"

View File

@@ -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_}`;

View File

@@ -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';