From 43401210c3691ffd8f8ac78b67bd5898e7981335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?= Date: Fri, 19 Jan 2024 07:58:07 +0900 Subject: [PATCH] refactor: fully typed locales (#13033) * refactor: fully typed locales * refactor: hide parameterized locale strings from type data in ts access * refactor: missing assertions * docs: annotation --- locales/generateDTS.js | 195 ++++++++++++++++++---- locales/index.d.ts | 229 +++++++++++++------------- packages/frontend/src/i18n.ts | 5 +- packages/frontend/src/scripts/i18n.ts | 113 +++++++++++-- 4 files changed, 380 insertions(+), 162 deletions(-) diff --git a/locales/generateDTS.js b/locales/generateDTS.js index d3afdd6e1502..6eb5bd630daf 100644 --- a/locales/generateDTS.js +++ b/locales/generateDTS.js @@ -6,54 +6,171 @@ 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, + ); + if (!parameters.length) { + return ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); + } + return ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('ParameterizedString'), + [ + ts.factory.createUnionTypeNode( + parameters.map((parameter) => + ts.factory.createLiteralTypeNode( + ts.factory.createStringLiteral(parameter), + ), + ), + ), + ], + ); +} function createMembers(record) { - return Object.entries(record) - .map(([k, v]) => ts.factory.createPropertySignature( + return Object.entries(record).map(([k, v]) => + ts.factory.createPropertySignature( undefined, ts.factory.createStringLiteral(k), undefined, - typeof v === 'string' - ? ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - : ts.factory.createTypeLiteralNode(createMembers(v)), - )); + createMemberType(v), + ), + ); } export default function generateDTS() { const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); const members = createMembers(locale); const elements = [ - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('Locale'), - undefined, - undefined, - members, - ), ts.factory.createVariableStatement( [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], ts.factory.createVariableDeclarationList( - [ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('locales'), + [ + 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.createInterfaceDeclaration( + [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier('ParameterizedString'), + [ + ts.factory.createTypeParameterDeclaration( + undefined, + ts.factory.createIdentifier('T'), + ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('string'), + undefined, + ), + ), + ], + undefined, + [ + ts.factory.createPropertySignature( + undefined, + ts.factory.createComputedPropertyName( + ts.factory.createIdentifier('kParameters'), + ), undefined, - ts.factory.createTypeLiteralNode([ts.factory.createIndexSignature( + ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('T'), undefined, - [ts.factory.createParameterDeclaration( + ), + ), + ], + ), + 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('lang'), + 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('Locale'), + ts.factory.createIdentifier('ParameterizedString'), + [ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)], + ), + ts.factory.createTypeReferenceNode( + ts.factory.createIdentifier('ILocale'), undefined, ), - )]), - undefined, - )], - ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, + ]), + ), + ], + ), + 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( @@ -70,16 +187,28 @@ export default function generateDTS() { ), ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ]; - 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), - ); + 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`, `/* eslint-disable */ + fs.writeFileSync( + `${__dirname}/index.d.ts`, + `/* eslint-disable */ // This file is generated by locales/generateDTS.js // Do not edit this file directly. -${printed}`, 'utf-8'); +${printed}`, + 'utf-8', + ); } diff --git a/locales/index.d.ts b/locales/index.d.ts index a659e790cc49..a22cb6350716 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1,12 +1,19 @@ /* eslint-disable */ // This file is generated by locales/generateDTS.js // Do not edit this file directly. -export interface Locale { +declare const kParameters: unique symbol; +export interface ParameterizedString { + [kParameters]: T; +} +export interface ILocale { + [_: string]: string | ParameterizedString | ILocale; +} +export interface Locale extends ILocale { "_lang_": string; "headlineMisskey": string; "introMisskey": string; - "poweredByMisskeyDescription": string; - "monthAndDay": string; + "poweredByMisskeyDescription": ParameterizedString<"name">; + "monthAndDay": ParameterizedString<"month" | "day">; "search": string; "notifications": string; "username": string; @@ -18,7 +25,7 @@ export interface Locale { "cancel": string; "noThankYou": string; "enterUsername": string; - "renotedBy": string; + "renotedBy": ParameterizedString<"user">; "noNotes": string; "noNotifications": string; "instance": string; @@ -78,8 +85,8 @@ export interface Locale { "export": string; "files": string; "download": string; - "driveFileDeleteConfirm": string; - "unfollowConfirm": string; + "driveFileDeleteConfirm": ParameterizedString<"name">; + "unfollowConfirm": ParameterizedString<"name">; "exportRequested": string; "importRequested": string; "lists": string; @@ -183,9 +190,9 @@ export interface Locale { "wallpaper": string; "setWallpaper": string; "removeWallpaper": string; - "searchWith": string; + "searchWith": ParameterizedString<"q">; "youHaveNoLists": string; - "followConfirm": string; + "followConfirm": ParameterizedString<"name">; "proxyAccount": string; "proxyAccountDescription": string; "host": string; @@ -208,7 +215,7 @@ export interface Locale { "software": string; "version": string; "metadata": string; - "withNFiles": string; + "withNFiles": ParameterizedString<"n">; "monitor": string; "jobQueue": string; "cpuAndMemory": string; @@ -237,7 +244,7 @@ export interface Locale { "processing": string; "preview": string; "default": string; - "defaultValueIs": string; + "defaultValueIs": ParameterizedString<"value">; "noCustomEmojis": string; "noJobs": string; "federating": string; @@ -266,8 +273,8 @@ export interface Locale { "imageUrl": string; "remove": string; "removed": string; - "removeAreYouSure": string; - "deleteAreYouSure": string; + "removeAreYouSure": ParameterizedString<"x">; + "deleteAreYouSure": ParameterizedString<"x">; "resetAreYouSure": string; "areYouSure": string; "saved": string; @@ -285,8 +292,8 @@ export interface Locale { "messageRead": string; "noMoreHistory": string; "startMessaging": string; - "nUsersRead": string; - "agreeTo": string; + "nUsersRead": ParameterizedString<"n">; + "agreeTo": ParameterizedString<"0">; "agree": string; "agreeBelow": string; "basicNotesBeforeCreateAccount": string; @@ -298,7 +305,7 @@ export interface Locale { "images": string; "image": string; "birthday": string; - "yearsOld": string; + "yearsOld": ParameterizedString<"age">; "registeredDate": string; "location": string; "theme": string; @@ -353,9 +360,9 @@ export interface Locale { "thisYear": string; "thisMonth": string; "today": string; - "dayX": string; - "monthX": string; - "yearX": string; + "dayX": ParameterizedString<"day">; + "monthX": ParameterizedString<"month">; + "yearX": ParameterizedString<"year">; "pages": string; "integration": string; "connectService": string; @@ -420,7 +427,7 @@ export interface Locale { "recentlyUpdatedUsers": string; "recentlyRegisteredUsers": string; "recentlyDiscoveredUsers": string; - "exploreUsersCount": string; + "exploreUsersCount": ParameterizedString<"count">; "exploreFediverse": string; "popularTags": string; "userList": string; @@ -437,16 +444,16 @@ export interface Locale { "moderationNote": string; "addModerationNote": string; "moderationLogs": string; - "nUsersMentioned": string; + "nUsersMentioned": ParameterizedString<"n">; "securityKeyAndPasskey": string; "securityKey": string; "lastUsed": string; - "lastUsedAt": string; + "lastUsedAt": ParameterizedString<"t">; "unregister": string; "passwordLessLogin": string; "passwordLessLoginDescription": string; "resetPassword": string; - "newPasswordIs": string; + "newPasswordIs": ParameterizedString<"password">; "reduceUiAnimation": string; "share": string; "notFound": string; @@ -466,7 +473,7 @@ export interface Locale { "enable": string; "next": string; "retype": string; - "noteOf": string; + "noteOf": ParameterizedString<"user">; "quoteAttached": string; "quoteQuestion": string; "noMessagesYet": string; @@ -486,12 +493,12 @@ export interface Locale { "strongPassword": string; "passwordMatched": string; "passwordNotMatched": string; - "signinWith": string; + "signinWith": ParameterizedString<"x">; "signinFailed": string; "or": string; "language": string; "uiLanguage": string; - "aboutX": string; + "aboutX": ParameterizedString<"x">; "emojiStyle": string; "native": string; "disableDrawer": string; @@ -509,7 +516,7 @@ export interface Locale { "regenerate": string; "fontSize": string; "mediaListWithOneImageAppearance": string; - "limitTo": string; + "limitTo": ParameterizedString<"x">; "noFollowRequests": string; "openImageInNewTab": string; "dashboard": string; @@ -587,7 +594,7 @@ export interface Locale { "deleteAllFiles": string; "deleteAllFilesConfirm": string; "removeAllFollowing": string; - "removeAllFollowingDescription": string; + "removeAllFollowingDescription": ParameterizedString<"host">; "userSuspended": string; "userSilenced": string; "yourAccountSuspendedTitle": string; @@ -658,9 +665,9 @@ export interface Locale { "wordMute": string; "hardWordMute": string; "regexpError": string; - "regexpErrorDescription": string; + "regexpErrorDescription": ParameterizedString<"tab" | "line">; "instanceMute": string; - "userSaysSomething": string; + "userSaysSomething": ParameterizedString<"name">; "makeActive": string; "display": string; "copy": string; @@ -686,7 +693,7 @@ export interface Locale { "abuseReports": string; "reportAbuse": string; "reportAbuseRenote": string; - "reportAbuseOf": string; + "reportAbuseOf": ParameterizedString<"name">; "fillAbuseReportDescription": string; "abuseReported": string; "reporter": string; @@ -701,7 +708,7 @@ export interface Locale { "defaultNavigationBehaviour": string; "editTheseSettingsMayBreakAccount": string; "instanceTicker": string; - "waitingFor": string; + "waitingFor": ParameterizedString<"x">; "random": string; "system": string; "switchUi": string; @@ -711,10 +718,10 @@ export interface Locale { "optional": string; "createNewClip": string; "unclip": string; - "confirmToUnclipAlreadyClippedNote": string; + "confirmToUnclipAlreadyClippedNote": ParameterizedString<"name">; "public": string; "private": string; - "i18nInfo": string; + "i18nInfo": ParameterizedString<"link">; "manageAccessTokens": string; "accountInfo": string; "notesCount": string; @@ -764,9 +771,9 @@ export interface Locale { "needReloadToApply": string; "showTitlebar": string; "clearCache": string; - "onlineUsersCount": string; - "nUsers": string; - "nNotes": string; + "onlineUsersCount": ParameterizedString<"n">; + "nUsers": ParameterizedString<"n">; + "nNotes": ParameterizedString<"n">; "sendErrorReports": string; "sendErrorReportsDescription": string; "myTheme": string; @@ -798,7 +805,7 @@ export interface Locale { "publish": string; "inChannelSearch": string; "useReactionPickerForContextMenu": string; - "typingUsers": string; + "typingUsers": ParameterizedString<"users">; "jumpToSpecifiedDate": string; "showingPastTimeline": string; "clear": string; @@ -865,7 +872,7 @@ export interface Locale { "misskeyUpdated": string; "whatIsNew": string; "translate": string; - "translatedFrom": string; + "translatedFrom": ParameterizedString<"x">; "accountDeletionInProgress": string; "usernameInfo": string; "aiChanMode": string; @@ -896,11 +903,11 @@ export interface Locale { "continueThread": string; "deleteAccountConfirm": string; "incorrectPassword": string; - "voteConfirm": string; + "voteConfirm": ParameterizedString<"choice">; "hide": string; "useDrawerReactionPickerForMobile": string; - "welcomeBackWithName": string; - "clickToFinishEmailVerification": string; + "welcomeBackWithName": ParameterizedString<"name">; + "clickToFinishEmailVerification": ParameterizedString<"ok">; "overridedDeviceKind": string; "smartphone": string; "tablet": string; @@ -928,8 +935,8 @@ export interface Locale { "cropYes": string; "cropNo": string; "file": string; - "recentNHours": string; - "recentNDays": string; + "recentNHours": ParameterizedString<"n">; + "recentNDays": ParameterizedString<"n">; "noEmailServerWarning": string; "thereIsUnresolvedAbuseReportWarning": string; "recommended": string; @@ -938,7 +945,7 @@ export interface Locale { "driveCapOverrideCaption": string; "requireAdminForView": string; "isSystemAccount": string; - "typeToConfirm": string; + "typeToConfirm": ParameterizedString<"x">; "deleteAccount": string; "document": string; "numberOfPageCache": string; @@ -992,7 +999,7 @@ export interface Locale { "neverShow": string; "remindMeLater": string; "didYouLikeMisskey": string; - "pleaseDonate": string; + "pleaseDonate": ParameterizedString<"host">; "roles": string; "role": string; "noRole": string; @@ -1090,7 +1097,7 @@ export interface Locale { "preservedUsernamesDescription": string; "createNoteFromTheFile": string; "archive": string; - "channelArchiveConfirmTitle": string; + "channelArchiveConfirmTitle": ParameterizedString<"name">; "channelArchiveConfirmDescription": string; "thisChannelArchived": string; "displayOfNote": string; @@ -1120,8 +1127,8 @@ export interface Locale { "createCount": string; "inviteCodeCreated": string; "inviteLimitExceeded": string; - "createLimitRemaining": string; - "inviteLimitResetCycle": string; + "createLimitRemaining": ParameterizedString<"limit">; + "inviteLimitResetCycle": ParameterizedString<"time" | "limit">; "expirationDate": string; "noExpirationDate": string; "inviteCodeUsedAt": string; @@ -1134,7 +1141,7 @@ export interface Locale { "expired": string; "doYouAgree": string; "beSureToReadThisAsItIsImportant": string; - "iHaveReadXCarefullyAndAgree": string; + "iHaveReadXCarefullyAndAgree": ParameterizedString<"x">; "dialog": string; "icon": string; "forYou": string; @@ -1189,7 +1196,7 @@ export interface Locale { "doReaction": string; "code": string; "reloadRequiredToApplySettings": string; - "remainingN": string; + "remainingN": ParameterizedString<"n">; "overwriteContentConfirm": string; "seasonalScreenEffect": string; "decorate": string; @@ -1202,7 +1209,7 @@ export interface Locale { "replay": string; "replaying": string; "ranking": string; - "lastNDays": string; + "lastNDays": ParameterizedString<"n">; "backToTitle": string; "enableHorizontalSwipe": string; "_bubbleGame": { @@ -1221,7 +1228,7 @@ export interface Locale { "end": string; "tooManyActiveAnnouncementDescription": string; "readConfirmTitle": string; - "readConfirmText": string; + "readConfirmText": ParameterizedString<"title">; "shouldNotBeUsedToPresentPermanentInfo": string; "dialogAnnouncementUxWarn": string; "silence": string; @@ -1236,10 +1243,10 @@ export interface Locale { "theseSettingsCanEditLater": string; "youCanEditMoreSettingsInSettingsPageLater": string; "followUsers": string; - "pushNotificationDescription": string; + "pushNotificationDescription": ParameterizedString<"name">; "initialAccountSettingCompleted": string; - "haveFun": string; - "youCanContinueTutorial": string; + "haveFun": ParameterizedString<"name">; + "youCanContinueTutorial": ParameterizedString<"name">; "startTutorial": string; "skipAreYouSure": string; "laterAreYouSure": string; @@ -1277,7 +1284,7 @@ export interface Locale { "social": string; "global": string; "description2": string; - "description3": string; + "description3": ParameterizedString<"link">; }; "_postNote": { "title": string; @@ -1315,7 +1322,7 @@ export interface Locale { }; "_done": { "title": string; - "description": string; + "description": ParameterizedString<"link">; }; }; "_timelineDescription": { @@ -1329,10 +1336,10 @@ export interface Locale { }; "_serverSettings": { "iconUrl": string; - "appIconDescription": string; + "appIconDescription": ParameterizedString<"host">; "appIconUsageExample": string; "appIconStyleRecommendation": string; - "appIconResolutionMustBe": string; + "appIconResolutionMustBe": ParameterizedString<"resolution">; "manifestJsonOverride": string; "shortName": string; "shortNameDescription": string; @@ -1343,7 +1350,7 @@ export interface Locale { "_accountMigration": { "moveFrom": string; "moveFromSub": string; - "moveFromLabel": string; + "moveFromLabel": ParameterizedString<"n">; "moveFromDescription": string; "moveTo": string; "moveToLabel": string; @@ -1351,7 +1358,7 @@ export interface Locale { "moveAccountDescription": string; "moveAccountHowTo": string; "startMigration": string; - "migrationConfirm": string; + "migrationConfirm": ParameterizedString<"account">; "movedAndCannotBeUndone": string; "postMigrationNote": string; "movedTo": string; @@ -1793,7 +1800,7 @@ export interface Locale { "_signup": { "almostThere": string; "emailAddressInfo": string; - "emailSent": string; + "emailSent": ParameterizedString<"email">; }; "_accountDelete": { "accountDelete": string; @@ -1846,14 +1853,14 @@ export interface Locale { "save": string; "inputName": string; "cannotSave": string; - "nameAlreadyExists": string; - "applyConfirm": string; - "saveConfirm": string; - "deleteConfirm": string; - "renameConfirm": string; + "nameAlreadyExists": ParameterizedString<"name">; + "applyConfirm": ParameterizedString<"name">; + "saveConfirm": ParameterizedString<"name">; + "deleteConfirm": ParameterizedString<"name">; + "renameConfirm": ParameterizedString<"old" | "new">; "noBackups": string; - "createdAt": string; - "updatedAt": string; + "createdAt": ParameterizedString<"date" | "time">; + "updatedAt": ParameterizedString<"date" | "time">; "cannotLoad": string; "invalidFile": string; }; @@ -1898,8 +1905,8 @@ export interface Locale { "featured": string; "owned": string; "following": string; - "usersCount": string; - "notesCount": string; + "usersCount": ParameterizedString<"n">; + "notesCount": ParameterizedString<"n">; "nameAndDescription": string; "nameOnly": string; "allowRenoteToExternal": string; @@ -1927,7 +1934,7 @@ export interface Locale { "manage": string; "code": string; "description": string; - "installed": string; + "installed": ParameterizedString<"name">; "installedThemes": string; "builtinThemes": string; "alreadyInstalled": string; @@ -1950,7 +1957,7 @@ export interface Locale { "lighten": string; "inputConstantName": string; "importInfo": string; - "deleteConstantConfirm": string; + "deleteConstantConfirm": ParameterizedString<"const">; "keys": { "accent": string; "bg": string; @@ -2013,23 +2020,23 @@ export interface Locale { "_ago": { "future": string; "justNow": string; - "secondsAgo": string; - "minutesAgo": string; - "hoursAgo": string; - "daysAgo": string; - "weeksAgo": string; - "monthsAgo": string; - "yearsAgo": string; + "secondsAgo": ParameterizedString<"n">; + "minutesAgo": ParameterizedString<"n">; + "hoursAgo": ParameterizedString<"n">; + "daysAgo": ParameterizedString<"n">; + "weeksAgo": ParameterizedString<"n">; + "monthsAgo": ParameterizedString<"n">; + "yearsAgo": ParameterizedString<"n">; "invalid": string; }; "_timeIn": { - "seconds": string; - "minutes": string; - "hours": string; - "days": string; - "weeks": string; - "months": string; - "years": string; + "seconds": ParameterizedString<"n">; + "minutes": ParameterizedString<"n">; + "hours": ParameterizedString<"n">; + "days": ParameterizedString<"n">; + "weeks": ParameterizedString<"n">; + "months": ParameterizedString<"n">; + "years": ParameterizedString<"n">; }; "_time": { "second": string; @@ -2040,7 +2047,7 @@ export interface Locale { "_2fa": { "alreadyRegistered": string; "registerTOTP": string; - "step1": string; + "step1": ParameterizedString<"a" | "b">; "step2": string; "step2Click": string; "step2Uri": string; @@ -2055,7 +2062,7 @@ export interface Locale { "securityKeyName": string; "tapSecurityKey": string; "removeKey": string; - "removeKeyConfirm": string; + "removeKeyConfirm": ParameterizedString<"name">; "whyTOTPOnlyRenew": string; "renewTOTP": string; "renewTOTPConfirm": string; @@ -2156,9 +2163,9 @@ export interface Locale { }; "_auth": { "shareAccessTitle": string; - "shareAccess": string; + "shareAccess": ParameterizedString<"name">; "shareAccessAsk": string; - "permission": string; + "permission": ParameterizedString<"name">; "permissionAsk": string; "pleaseGoBack": string; "callback": string; @@ -2217,12 +2224,12 @@ export interface Locale { "_cw": { "hide": string; "show": string; - "chars": string; - "files": string; + "chars": ParameterizedString<"count">; + "files": ParameterizedString<"count">; }; "_poll": { "noOnlyOneChoice": string; - "choiceN": string; + "choiceN": ParameterizedString<"n">; "noMore": string; "canMultipleVote": string; "expiration": string; @@ -2232,16 +2239,16 @@ export interface Locale { "deadlineDate": string; "deadlineTime": string; "duration": string; - "votesCount": string; - "totalVotes": string; + "votesCount": ParameterizedString<"n">; + "totalVotes": ParameterizedString<"n">; "vote": string; "showResult": string; "voted": string; "closed": string; - "remainingDays": string; - "remainingHours": string; - "remainingMinutes": string; - "remainingSeconds": string; + "remainingDays": ParameterizedString<"d" | "h">; + "remainingHours": ParameterizedString<"h" | "m">; + "remainingMinutes": ParameterizedString<"m" | "s">; + "remainingSeconds": ParameterizedString<"s">; }; "_visibility": { "public": string; @@ -2281,7 +2288,7 @@ export interface Locale { "changeAvatar": string; "changeBanner": string; "verifiedLinkDescription": string; - "avatarDecorationMax": string; + "avatarDecorationMax": ParameterizedString<"max">; }; "_exportOrImport": { "allNotes": string; @@ -2404,16 +2411,16 @@ export interface Locale { }; "_notification": { "fileUploaded": string; - "youGotMention": string; - "youGotReply": string; - "youGotQuote": string; - "youRenoted": string; + "youGotMention": ParameterizedString<"name">; + "youGotReply": ParameterizedString<"name">; + "youGotQuote": ParameterizedString<"name">; + "youRenoted": ParameterizedString<"name">; "youWereFollowed": string; "youReceivedFollowRequest": string; "yourFollowRequestAccepted": string; "pollEnded": string; "newNote": string; - "unreadAntennaNote": string; + "unreadAntennaNote": ParameterizedString<"name">; "roleAssigned": string; "emptyPushNotificationMessage": string; "achievementEarned": string; @@ -2421,9 +2428,9 @@ export interface Locale { "checkNotificationBehavior": string; "sendTestNotification": string; "notificationWillBeDisplayedLikeThis": string; - "reactedBySomeUsers": string; - "renotedBySomeUsers": string; - "followedBySomeUsers": string; + "reactedBySomeUsers": ParameterizedString<"n">; + "renotedBySomeUsers": ParameterizedString<"n">; + "followedBySomeUsers": ParameterizedString<"n">; "_types": { "all": string; "note": string; @@ -2480,8 +2487,8 @@ export interface Locale { }; }; "_dialog": { - "charactersExceeded": string; - "charactersBelow": string; + "charactersExceeded": ParameterizedString<"current" | "max">; + "charactersBelow": ParameterizedString<"current" | "min">; }; "_disabledTimeline": { "title": string; diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts index 858db74dacf9..c5c4ccf82028 100644 --- a/packages/frontend/src/i18n.ts +++ b/packages/frontend/src/i18n.ts @@ -10,6 +10,7 @@ import { I18n } from '@/scripts/i18n.js'; export const i18n = markRaw(new I18n(locale)); -export function updateI18n(newLocale) { - i18n.ts = newLocale; +export function updateI18n(newLocale: Locale) { + // @ts-expect-error -- private field + i18n.locale = newLocale; } diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts index 8e5f17f38ae0..55b537195096 100644 --- a/packages/frontend/src/scripts/i18n.ts +++ b/packages/frontend/src/scripts/i18n.ts @@ -2,33 +2,114 @@ * SPDX-FileCopyrightText: syuilo and other misskey contributors * SPDX-License-Identifier: AGPL-3.0-only */ +import type { ILocale, ParameterizedString } from '../../../../locales/index.js'; -export class I18n> { - public ts: T; +type FlattenKeys = keyof { + [K in keyof T as T[K] extends ILocale + ? FlattenKeys extends infer C extends string + ? `${K & string}.${C}` + : never + : T[K] extends TPrediction + ? K + : never]: T[K]; +}; - constructor(locale: T) { - this.ts = locale; +type ParametersOf>> = T extends ILocale + ? TKey extends `${infer K}.${infer C}` + // @ts-expect-error -- C は明らかに FlattenKeys> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。 + ? ParametersOf + : TKey extends keyof T + ? T[TKey] extends ParameterizedString + ? P + : never + : never + : never; +type Ts = { + readonly [K in keyof T as T[K] extends ParameterizedString ? never : K]: T[K] extends ILocale ? Ts : string; +}; + +export class I18n { + constructor(private locale: T) { //#region BIND this.t = this.t.bind(this); //#endregion } - // string にしているのは、ドット区切りでのパス指定を許可するため - // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも - public t(key: string, args?: Record): string { - try { - let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + public get ts(): Ts { + if (_DEV_) { + class Handler implements ProxyHandler { + get(target: TTarget, p: string | symbol): unknown { + const value = target[p as keyof TTarget]; + + if (typeof value === 'object') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- 実際には null がくることはないので。 + return new Proxy(value!, new Handler()); + } + + if (typeof value === 'string') { + const parameters = Array.from(value.matchAll(/\{(\w+)\}/g)).map(([, parameter]) => parameter); + + if (parameters.length) { + console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); + } + + return value; + } + + console.error(`Unexpected locale key: ${String(p)}`); + + return p; + } + } + + return new Proxy(this.locale, new Handler()) as Ts; + } + + return this.locale as Ts; + } + + /** + * @deprecated なるべくこのメソッド使うよりも locale 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも + */ + public t>(key: TKey): string; + public t>>(key: TKey, args: { readonly [_ in ParametersOf]: string | number }): string; + public t(key: string, args?: { readonly [_: string]: string | number }) { + let str: string | ParameterizedString | ILocale = this.locale; + + for (const k of key.split('.')) { + str = str[k]; - if (args) { - for (const [k, v] of Object.entries(args)) { - str = str.replace(`{${k}}`, v.toString()); + if (_DEV_) { + if (typeof str === 'undefined') { + console.error(`Unexpected locale key: ${key}`); + return key; } } - return str; - } catch (err) { - console.warn(`missing localization '${key}'`); - return key; } + + if (args) { + if (_DEV_) { + const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter)); + + if (missing.length) { + console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`); + } + } + + for (const [k, v] of Object.entries(args)) { + const search = `{${k}}`; + + if (_DEV_) { + if (!(str as string).includes(search)) { + console.error(`Unexpected locale parameter: ${k} at ${key}`); + } + } + + str = (str as string).replace(search, v.toString()); + } + } + + return str; } }