mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-10-31 13:34:12 +00:00 
			
		
		
		
	merge: teach eslint to check translations (!695)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/695 Approved-by: Marie <github@yuugi.dev> Approved-by: Hazelnoot <acomputerdog@gmail.com>
This commit is contained in:
		
						commit
						55df1ad10f
					
				
					 13 changed files with 337 additions and 7 deletions
				
			
		
							
								
								
									
										251
									
								
								eslint/locale.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										251
									
								
								eslint/locale.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  | }; | ||||||
							
								
								
									
										54
									
								
								eslint/locale.test.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								eslint/locale.test.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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), | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | @ -4,6 +4,8 @@ import parser from 'vue-eslint-parser'; | ||||||
| import pluginVue from 'eslint-plugin-vue'; | import pluginVue from 'eslint-plugin-vue'; | ||||||
| import pluginMisskey from '@misskey-dev/eslint-plugin'; | import pluginMisskey from '@misskey-dev/eslint-plugin'; | ||||||
| import sharedConfig from '../shared/eslint.config.js'; | import sharedConfig from '../shared/eslint.config.js'; | ||||||
|  | import localeRule from '../../eslint/locale.js'; | ||||||
|  | import { build as buildLocales } from '../../locales/index.js'; | ||||||
| 
 | 
 | ||||||
| export default [ | export default [ | ||||||
| 	...sharedConfig, | 	...sharedConfig, | ||||||
|  | @ -14,6 +16,7 @@ export default [ | ||||||
| 	...pluginVue.configs['flat/recommended'], | 	...pluginVue.configs['flat/recommended'], | ||||||
| 	{ | 	{ | ||||||
| 		files: ['{src,test,js,@types}/**/*.{ts,vue}'], | 		files: ['{src,test,js,@types}/**/*.{ts,vue}'], | ||||||
|  | 		plugins: { sharkey: { rules: { locale: localeRule } } }, | ||||||
| 		languageOptions: { | 		languageOptions: { | ||||||
| 			globals: { | 			globals: { | ||||||
| 				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), | 				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), | ||||||
|  | @ -44,6 +47,8 @@ export default [ | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| 		rules: { | 		rules: { | ||||||
|  | 			'sharkey/locale': ['error', buildLocales()['en-US']], | ||||||
|  | 
 | ||||||
| 			'@typescript-eslint/no-empty-interface': ['error', { | 			'@typescript-eslint/no-empty-interface': ['error', { | ||||||
| 				allowSingleExtends: true, | 				allowSingleExtends: true, | ||||||
| 			}], | 			}], | ||||||
|  |  | ||||||
|  | @ -4,6 +4,8 @@ import parser from 'vue-eslint-parser'; | ||||||
| import pluginVue from 'eslint-plugin-vue'; | import pluginVue from 'eslint-plugin-vue'; | ||||||
| import pluginMisskey from '@misskey-dev/eslint-plugin'; | import pluginMisskey from '@misskey-dev/eslint-plugin'; | ||||||
| import sharedConfig from '../shared/eslint.config.js'; | import sharedConfig from '../shared/eslint.config.js'; | ||||||
|  | import localeRule from '../../eslint/locale.js'; | ||||||
|  | import { build as buildLocales } from '../../locales/index.js'; | ||||||
| 
 | 
 | ||||||
| export default [ | export default [ | ||||||
| 	...sharedConfig, | 	...sharedConfig, | ||||||
|  | @ -14,6 +16,7 @@ export default [ | ||||||
| 	...pluginVue.configs['flat/recommended'], | 	...pluginVue.configs['flat/recommended'], | ||||||
| 	{ | 	{ | ||||||
| 		files: ['{src,test,js,@types}/**/*.{ts,vue}'], | 		files: ['{src,test,js,@types}/**/*.{ts,vue}'], | ||||||
|  | 		plugins: { sharkey: { rules: { locale: localeRule } } }, | ||||||
| 		languageOptions: { | 		languageOptions: { | ||||||
| 			globals: { | 			globals: { | ||||||
| 				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), | 				...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), | ||||||
|  | @ -44,6 +47,8 @@ export default [ | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
| 		rules: { | 		rules: { | ||||||
|  | 			'sharkey/locale': ['error', buildLocales()['en-US']], | ||||||
|  | 
 | ||||||
| 			'@typescript-eslint/no-empty-interface': ['error', { | 			'@typescript-eslint/no-empty-interface': ['error', { | ||||||
| 				allowSingleExtends: true, | 				allowSingleExtends: true, | ||||||
| 			}], | 			}], | ||||||
|  |  | ||||||
|  | @ -138,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 			</div> | 			</div> | ||||||
| 
 | 
 | ||||||
| 			<div v-else-if="tab === 'announcements'" class="_gaps"> | 			<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"> | 				<MkPagination :pagination="announcementsPagination"> | ||||||
| 					<template #default="{ items }"> | 					<template #default="{ items }"> | ||||||
|  |  | ||||||
|  | @ -100,7 +100,7 @@ async function init() { | ||||||
| 
 | 
 | ||||||
| async function testEmail() { | async function testEmail() { | ||||||
| 	const { canceled, result: destination } = await os.inputText({ | 	const { canceled, result: destination } = await os.inputText({ | ||||||
| 		title: i18n.ts.destination, | 		title: i18n.ts.emailDestination, | ||||||
| 		type: 'email', | 		type: 'email', | ||||||
| 		default: instance.maintainerEmail ?? '', | 		default: instance.maintainerEmail ?? '', | ||||||
| 		placeholder: 'test@example.com', | 		placeholder: 'test@example.com', | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 				<h1>{{ i18n.ts._auth.denied }}</h1> | 				<h1>{{ i18n.ts._auth.denied }}</h1> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div v-if="state == 'accepted' && session"> | 			<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"> | 				<p v-if="session.app.callbackUrl"> | ||||||
| 					{{ i18n.ts._auth.callback }} | 					{{ i18n.ts._auth.callback }} | ||||||
| 					<MkEllipsis/> | 					<MkEllipsis/> | ||||||
|  |  | ||||||
|  | @ -266,7 +266,7 @@ function showMenu(ev: MouseEvent) { | ||||||
| 	if ($i && $i.id === page.value.userId) { | 	if ($i && $i.id === page.value.userId) { | ||||||
| 		menuItems.push({ | 		menuItems.push({ | ||||||
| 			icon: 'ti ti-pencil', | 			icon: 'ti ti-pencil', | ||||||
| 			text: i18n.ts.editThisPage, | 			text: i18n.ts._pages.editThisPage, | ||||||
| 			action: () => router.push(`/pages/edit/${page.value.id}`), | 			action: () => router.push(`/pages/edit/${page.value.id}`), | ||||||
| 		}); | 		}); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 			<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> | 			<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> | ||||||
| 
 | 
 | ||||||
| 			<FormSection v-if="keys"> | 			<FormSection v-if="keys"> | ||||||
| 				<template #label>{{ i18n.ts.keys }}</template> | 				<template #label>{{ i18n.ts._registry.keys }}</template> | ||||||
| 				<div class="_gaps_s"> | 				<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> | 					<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> | 				</div> | ||||||
|  |  | ||||||
|  | @ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 			<div class="top"> | 			<div class="top"> | ||||||
| 				<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> | 				<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> | ||||||
| 				<p class="status"> | 				<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="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> | 					<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> | ||||||
| 				</p> | 				</p> | ||||||
|  |  | ||||||
|  | @ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||||
| 	<template #header>{{ i18n.ts._widgets.memo }}</template> | 	<template #header>{{ i18n.ts._widgets.memo }}</template> | ||||||
| 
 | 
 | ||||||
| 	<div :class="$style.root"> | 	<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> | 		<button :class="$style.save" :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button> | ||||||
| 	</div> | 	</div> | ||||||
| </MkContainer> | </MkContainer> | ||||||
|  |  | ||||||
|  | @ -7,6 +7,8 @@ import { describe, expect, it } from 'vitest'; | ||||||
| import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので
 | import { I18n } from '../../frontend-shared/js/i18n.js'; // @@で参照できなかったので
 | ||||||
| import { ParameterizedString } from '../../../locales/index.js'; | import { ParameterizedString } from '../../../locales/index.js'; | ||||||
| 
 | 
 | ||||||
|  | /* eslint "sharkey/locale":"off" */ | ||||||
|  | 
 | ||||||
| // TODO: このテストはfrontend-sharedに移動する
 | // TODO: このテストはfrontend-sharedに移動する
 | ||||||
| 
 | 
 | ||||||
| describe('i18n', () => { | describe('i18n', () => { | ||||||
|  |  | ||||||
|  | @ -9,6 +9,12 @@ openRemoteProfile: "Open remote profile" | ||||||
| trustedLinkUrlPatterns: "Link to external site warning exclusion list" | 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." | 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" | mutuals: "Mutuals" | ||||||
|  | isLocked: "Private account" | ||||||
|  | isAdmin: "Administrator" | ||||||
|  | isBot: "Bot user" | ||||||
|  | open: "Open" | ||||||
|  | emailDestination: "Destination address" | ||||||
|  | date: "Date" | ||||||
| renote: "Boost" | renote: "Boost" | ||||||
| unrenote: "Remove boost" | unrenote: "Remove boost" | ||||||
| renoted: "Boosted." | renoted: "Boosted." | ||||||
|  | @ -149,6 +155,7 @@ showNonPublicNotes: "Show non-public" | ||||||
| allowClickingNotifications: "Allow clicking on pop-up notifications" | allowClickingNotifications: "Allow clicking on pop-up notifications" | ||||||
| pinnedOnly: "Pinned" | pinnedOnly: "Pinned" | ||||||
| blockingYou: "Blocking you" | blockingYou: "Blocking you" | ||||||
|  | warnExternalUrl: "Show warning when opening external URLs" | ||||||
| _delivery: | _delivery: | ||||||
|   stop: "Suspend delivery" |   stop: "Suspend delivery" | ||||||
|   resume: "Resume delivery" |   resume: "Resume delivery" | ||||||
|  | @ -383,4 +390,10 @@ _externalNavigationWarning: | ||||||
|   title: "Navigate to an external site" |   title: "Navigate to an external site" | ||||||
|   description: "Leave {host} and go to an external site" |   description: "Leave {host} and go to an external site" | ||||||
|   trustThisDomain: "Trust this domain on this device in the future" |   trustThisDomain: "Trust this domain on this device in the future" | ||||||
|  | 
 | ||||||
| remoteFollowersWarning: "Remote followers may have incomplete or outdated activity" | remoteFollowersWarning: "Remote followers may have incomplete or outdated activity" | ||||||
|  | 
 | ||||||
|  | _auth: | ||||||
|  |   allowed: "Allowed" | ||||||
|  | _announcement: | ||||||
|  |   new: "New" | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue