diff --git a/eslint/locale.js b/eslint/locale.js new file mode 100644 index 0000000000000000000000000000000000000000..dbb807b71421e06c66763cb735cd3be8c4182ac5 --- /dev/null +++ b/eslint/locale.js @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: dakkar and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only +*/ + +/* This is a ESLint rule to report use of the `i18n.ts` and `i18n.tsx` + * objects that reference translation items that don't actually exist + * in the lexicon (the `locale/` files) + */ + +/* given a MemberExpression node, collects all the member names + * + * e.g. for a bit of code like `foo=one.two.three`, `collectMembers` + * called on the node for `three` would return `['one', 'two', + * 'three']` + */ +function collectMembers(node) { + if (!node) return []; + if (node.type !== 'MemberExpression') return []; + // this is something like `foo[bar]` + if (node.computed) return []; + return [ node.property.name, ...collectMembers(node.parent) ]; +} + +/* given an object and an array of names, recursively descends the + * object via those names + * + * e.g. `walkDown({one:{two:{three:15}}},['one','two','three'])` would + * return 15 + */ +function walkDown(locale, path) { + if (!locale) return null; + if (!path || path.length === 0 || !path[0]) return locale; + return walkDown(locale[path[0]], path.slice(1)); +} + +/* given a MemberExpression node, returns its attached CallExpression + * node if present + * + * e.g. for a bit of code like `foo=one.two.three()`, + * `findCallExpression` called on the node for `three` would return + * the node for function call (which is the parent of the `one` and + * `two` nodes, and holds the nodes for the argument list) + * + * if the code had been `foo=one.two.three`, `findCallExpression` + * would have returned null, because there's no function call attached + * to the MemberExpressions + */ +function findCallExpression(node) { + if (!node.parent) return null; + + // the second half of this guard protects from cases like + // `foo(one.two.three)` where the CallExpression is parent of the + // MemberExpressions, but via `arguments`, not `callee` + if (node.parent.type === 'CallExpression' && node.parent.callee === node) return node.parent; + if (node.parent.type === 'MemberExpression') return findCallExpression(node.parent); + return null; +} + +// same, but for Vue expressions (`<I18n :src="i18n.ts.foo">`) +function findVueExpression(node) { + if (!node.parent) return null; + + if (node.parent.type.match(/^VExpr/) && node.parent.expression === node) return node.parent; + if (node.parent.type === 'MemberExpression') return findVueExpression(node.parent); + return null; +} + +function areArgumentsOneObject(node) { + return node.arguments.length === 1 && + node.arguments[0].type === 'ObjectExpression'; +} + +// only call if `areArgumentsOneObject(node)` is true +function getArgumentObjectProperties(node) { + return new Set(node.arguments[0].properties.map( + p => { + if (p.key && p.key.type === 'Identifier') return p.key.name; + return null; + }, + )); +} + +function getTranslationParameters(translation) { + return new Set(Array.from(translation.matchAll(/\{(\w+)\}/g)).map( m => m[1] )); +} + +function setDifference(a,b) { + const result = []; + for (const element of a.values()) { + if (!b.has(element)) { + result.push(element); + } + } + + return result; +} + +/* the actual rule body + */ +function theRuleBody(context,node) { + // we get the locale/translations via the options; it's the data + // that goes into a specific language's JSON file, see + // `scripts/build-assets.mjs` + const locale = context.options[0]; + + // sometimes we get MemberExpression nodes that have a + // *descendent* with the right identifier: skip them, we'll get + // the right ones as well + if (node.object?.name !== 'i18n') { + return; + } + + // `method` is going to be `'ts'` or `'tsx'`, `path` is going to + // be the various translation steps/names + const [ method, ...path ] = collectMembers(node); + const pathStr = `i18n.${method}.${path.join('.')}`; + + // does that path point to a real translation? + const translation = walkDown(locale, path); + if (!translation) { + context.report({ + node, + message: `translation missing for ${pathStr}`, + }); + return; + } + + // we hit something weird, assume the programmers know what + // they're doing (this is usually some complicated slicing of + // the translation structure) + if (typeof(translation) !== 'string') return; + + const callExpression = findCallExpression(node); + const vueExpression = findVueExpression(node); + + // some more checks on how the translation is called + if (method === 'ts') { + // the `<I18n> component gets parametric translations via + // `i18n.ts.*`, but we error out elsewhere + if (translation.match(/\{/) && !vueExpression) { + context.report({ + node, + message: `translation for ${pathStr} is parametric, but called via 'ts'`, + }); + return; + } + + if (callExpression) { + context.report({ + node, + message: `translation for ${pathStr} is not parametric, but is called as a function`, + }); + } + } + + if (method === 'tsx') { + if (!translation.match(/\{/)) { + context.report({ + node, + message: `translation for ${pathStr} is not parametric, but called via 'tsx'`, + }); + return; + } + + if (!callExpression && !vueExpression) { + context.report({ + node, + message: `translation for ${pathStr} is parametric, but not called as a function`, + }); + return; + } + + // we're not currently checking arguments when used via the + // `<I18n>` component, because it's too complicated (also, it + // would have to be done inside the `if (method === 'ts')`) + if (!callExpression) return; + + if (!areArgumentsOneObject(callExpression)) { + context.report({ + node, + message: `translation for ${pathStr} should be called with a single object as argument`, + }); + return; + } + + const translationParameters = getTranslationParameters(translation); + const parameterCount = translationParameters.size; + const callArguments = getArgumentObjectProperties(callExpression); + const argumentCount = callArguments.size; + + if (parameterCount !== argumentCount) { + context.report({ + node, + message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`, + }); + } + + // node 20 doesn't have `Set.difference`... + const extraArguments = setDifference(callArguments, translationParameters); + const missingArguments = setDifference(translationParameters, callArguments); + + if (extraArguments.length > 0) { + context.report({ + node, + message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`, + }); + } + + if (missingArguments.length > 0) { + context.report({ + node, + message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`, + }); + } + } +} + +function theRule(context) { + // we get the locale/translations via the options; it's the data + // that goes into a specific language's JSON file, see + // `scripts/build-assets.mjs` + const locale = context.options[0]; + + // for all object member access that have an identifier 'i18n'... + return context.getSourceCode().parserServices.defineTemplateBodyVisitor( + { + // this is for <template> bits, needs work + 'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node), + }, + { + // this is for normal code + 'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node), + }, + ); +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'assert that all translations used are present in the locale files', + }, + schema: [ + // here we declare that we need the locale/translation as a + // generic object + { type: 'object', additionalProperties: true }, + ], + }, + create: theRule, +}; diff --git a/eslint/locale.test.js b/eslint/locale.test.js new file mode 100644 index 0000000000000000000000000000000000000000..2b69672d27b80c11c763f9182b6750d4a09328ee --- /dev/null +++ b/eslint/locale.test.js @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: dakkar and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only +*/ + +const {RuleTester} = require("eslint"); +const localeRule = require("./locale"); + +const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' }; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require('vue-eslint-parser'), + ecmaVersion: 2015, + }, +}); + +function testCase(code,errors) { + return { code, errors, options: [ locale ], filename: 'test.ts' }; +} +function testCaseVue(code,errors) { + return { code, errors, options: [ locale ], filename: 'test.vue' }; +} + +ruleTester.run( + 'sharkey-locale', + localeRule, + { + valid: [ + testCase('i18n.ts.foo.bar'), + testCase('i18n.ts.top'), + testCase('i18n.tsx.foo.baz({x:1})'), + testCase('whatever.i18n.ts.blah.blah'), + testCase('whatever.i18n.tsx.does.not.matter'), + testCase('whatever(i18n.ts.foo.bar)'), + testCaseVue('<template><p>{{ i18n.ts.foo.bar }}</p></template>'), + testCaseVue('<template><I18n :src="i18n.ts.foo.baz"/></template>'), + // we don't detect the problem here, but should still accept it + testCase('i18n.ts.foo["something"]'), + testCase('i18n.ts.foo[something]'), + ], + invalid: [ + testCase('i18n.ts.not', 1), + testCase('i18n.tsx.deep.not', 1), + testCase('i18n.tsx.deep.not({x:12})', 1), + testCase('i18n.tsx.top({x:1})', 1), + testCase('i18n.ts.foo.baz', 1), + testCase('i18n.tsx.foo.baz', 1), + testCase('i18n.tsx.foo.baz({y:2})', 2), + testCaseVue('<template><p>{{ i18n.ts.not }}</p></template>', 1), + testCaseVue('<template><I18n :src="i18n.ts.not"/></template>', 1), + ], + }, +); diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 2d617b3adebfba8259b3be423ba5edf9522f8cf2..e686da9cc3d2aa5d34469885decec1f0614dbd5a 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -4,6 +4,8 @@ import parser from 'vue-eslint-parser'; import pluginVue from 'eslint-plugin-vue'; import pluginMisskey from '@misskey-dev/eslint-plugin'; import sharedConfig from '../shared/eslint.config.js'; +import localeRule from '../../eslint/locale.js'; +import { build as buildLocales } from '../../locales/index.js'; export default [ ...sharedConfig, @@ -14,6 +16,7 @@ export default [ ...pluginVue.configs['flat/recommended'], { files: ['{src,test,js,@types}/**/*.{ts,vue}'], + plugins: { sharkey: { rules: { locale: localeRule } } }, languageOptions: { globals: { ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), @@ -44,6 +47,8 @@ export default [ }, }, rules: { + 'sharkey/locale': ['error', buildLocales()['en-US']], + '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 28796e8d6bba6ed625c2bafc06a29718119ddaf4..565b3d6ae7b69d00c969586d359457d875cb7b19 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -4,6 +4,8 @@ import parser from 'vue-eslint-parser'; import pluginVue from 'eslint-plugin-vue'; import pluginMisskey from '@misskey-dev/eslint-plugin'; import sharedConfig from '../shared/eslint.config.js'; +import localeRule from '../../eslint/locale.js'; +import { build as buildLocales } from '../../locales/index.js'; export default [ ...sharedConfig, @@ -14,6 +16,7 @@ export default [ ...pluginVue.configs['flat/recommended'], { files: ['{src,test,js,@types}/**/*.{ts,vue}'], + plugins: { sharkey: { rules: { locale: localeRule } } }, languageOptions: { globals: { ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), @@ -44,6 +47,8 @@ export default [ }, }, rules: { + 'sharkey/locale': ['error', buildLocales()['en-US']], + '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 24894fff728e35f3fc5306f3781858f8f5d70223..22e16effe0272fde1e06a7c69ac0736ddf6266e8 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -138,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'announcements'" class="_gaps"> - <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> + <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts._announcement.new }}</MkButton> <MkPagination :pagination="announcementsPagination"> <template #default="{ items }"> diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index 4a858887f353a0c0774e39ef25d36df4383fad3f..ddfe5ae81f7b8573b1b7ecb7d1d2368c81ab9aee 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -100,7 +100,7 @@ async function init() { async function testEmail() { const { canceled, result: destination } = await os.inputText({ - title: i18n.ts.destination, + title: i18n.ts.emailDestination, type: 'email', default: instance.maintainerEmail ?? '', placeholder: 'test@example.com', diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index b9f45e219c110598fccf92a9728d9e36c1df3a34..75a44802f8b9468bb8d07249bb3d629c7cb96361 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only <h1>{{ i18n.ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted' && session"> - <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts._auth.allowed }}</h1> <p v-if="session.app.callbackUrl"> {{ i18n.ts._auth.callback }} <MkEllipsis/> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 89dd6e10d20bb7014364d50b374d2790d9aa22f7..12b70fa64f41a62ba4469f058935a5f6768b1dac 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -266,7 +266,7 @@ function showMenu(ev: MouseEvent) { if ($i && $i.id === page.value.userId) { menuItems.push({ icon: 'ti ti-pencil', - text: i18n.ts.editThisPage, + text: i18n.ts._pages.editThisPage, action: () => router.push(`/pages/edit/${page.value.id}`), }); diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index bac1d2bb700752e7bfc2f142d9c2266acec87412..8dcb8fa477a898a72f331e8f3adbefdb099d21c4 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <FormSection v-if="keys"> - <template #label>{{ i18n.ts.keys }}</template> + <template #label>{{ i18n.ts._registry.keys }}</template> <div class="_gaps_s"> <FormLink v-for="key in keys" :to="`/registry/value/${props.domain}/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> </div> diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue index 244bac6f102eadcc8b7e17c424b564be9762d953..96030c7897c9286c4dc16589432608bcd6ac7f32 100644 --- a/packages/frontend/src/ui/_common_/upload.vue +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="top"> <p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> <p class="status"> - <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> + <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.uploading }}</span> <span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> <span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> </p> diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue index b69152b4feaa3be66ca57a2dfb46c68e0d8753b6..ee89beb944c00b11af20ec1455e2dfa50445f362 100644 --- a/packages/frontend/src/widgets/WidgetMemo.vue +++ b/packages/frontend/src/widgets/WidgetMemo.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>{{ i18n.ts._widgets.memo }}</template> <div :class="$style.root"> - <textarea v-model="text" :style="`height: ${widgetProps.height}px;`" :class="$style.textarea" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea> + <textarea v-model="text" :style="`height: ${widgetProps.height}px;`" :class="$style.textarea" @input="onChange"></textarea> <button :class="$style.save" :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button> </div> </MkContainer> diff --git a/packages/frontend/test/i18n.test.ts b/packages/frontend/test/i18n.test.ts index 9d6cf855f340a91ae179cbd3846d50862065f1e5..149815d39c76f42674a9875cad37f777a17b6f8a 100644 --- a/packages/frontend/test/i18n.test.ts +++ b/packages/frontend/test/i18n.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it } from 'vitest'; import { I18n } from '../../frontend-shared/js/i18n.js'; // @@ã§å‚ç…§ã§ããªã‹ã£ãŸã®ã§ import { ParameterizedString } from '../../../locales/index.js'; +/* eslint "sharkey/locale":"off" */ + // TODO: ã“ã®ãƒ†ã‚¹ãƒˆã¯frontend-sharedã«ç§»å‹•ã™ã‚‹ describe('i18n', () => { diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 63860c3eb32e5fb531f69b3c8560156de170ee09..163fd0b0ae8ba90879280aeba31212c3dc72a4e2 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -9,6 +9,12 @@ openRemoteProfile: "Open remote profile" trustedLinkUrlPatterns: "Link to external site warning exclusion list" trustedLinkUrlPatternsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition. Using surrounding keywords with slashes will turn them into a regular expression. If you write only the domain name, it will be a backward match." mutuals: "Mutuals" +isLocked: "Private account" +isAdmin: "Administrator" +isBot: "Bot user" +open: "Open" +emailDestination: "Destination address" +date: "Date" renote: "Boost" unrenote: "Remove boost" renoted: "Boosted." @@ -149,6 +155,7 @@ showNonPublicNotes: "Show non-public" allowClickingNotifications: "Allow clicking on pop-up notifications" pinnedOnly: "Pinned" blockingYou: "Blocking you" +warnExternalUrl: "Show warning when opening external URLs" _delivery: stop: "Suspend delivery" resume: "Resume delivery" @@ -383,4 +390,10 @@ _externalNavigationWarning: title: "Navigate to an external site" description: "Leave {host} and go to an external site" trustThisDomain: "Trust this domain on this device in the future" + remoteFollowersWarning: "Remote followers may have incomplete or outdated activity" + +_auth: + allowed: "Allowed" +_announcement: + new: "New"