1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-23 16:54:10 +02:00

Merge branch 'develop' into copilot/add-user-mute-settings

This commit is contained in:
kakkokari-gtyih
2025-12-02 14:19:48 +09:00
180 changed files with 4058 additions and 3445 deletions

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,46 @@
{
"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"
},
"./const": {
"types": "./built/const.d.ts",
"import": "./built/const.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

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export 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;
export const primaries = {
'en': 'US',
'ja': 'JP',
'zh': 'CN',
} as const satisfies Record<string, string>;

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

@@ -0,0 +1,130 @@
/*
* 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 { languages, primaries } from './const.js';
import type { Locale } from './autogen/locale.js';
import type { ILocale, ParameterizedString } from './types.js';
type Language = typeof languages[number];
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, languages, 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"
]
}