diff options
Diffstat (limited to 'packages/i18n/scripts')
| -rw-r--r-- | packages/i18n/scripts/generateLocaleInterface.ts | 153 | ||||
| -rw-r--r-- | packages/i18n/scripts/verify.ts | 70 |
2 files changed, 223 insertions, 0 deletions
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); +} |